Class: Coupon::LineItemDiscountAllocator

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

Overview

Service object: line item discount allocator.

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

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

Returns a new instance of LineItemDiscountAllocator.

Parameters:

  • line_item (LineItem)

    line item discounts are allocated onto

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

    allocator options

Options Hash (options):

  • logger (Logger) — default: defaults to Rails.logger


10
11
12
13
# File 'app/services/coupon/line_item_discount_allocator.rb', line 10

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.



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

def line_item
  @line_item
end

#loggerObject (readonly)

Returns the value of attribute logger.



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

def logger
  @logger
end

#max_discountObject (readonly)

Returns the value of attribute max_discount.



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

def max_discount
  @max_discount
end

Instance Method Details

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

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.

Parameters:

  • discount (Discount)

    the discount instance in use

  • allocated_amount (Numeric)

    per-unit amount allocated; negative for a discount

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

    allocation options

Options Hash (options):

  • preserve_amount (Boolean)

    add to the existing line discount amount instead of resetting it (default false)

  • respect_catalog_maximum (Boolean)

    cap the discount at the catalog item's max discount (default true)

  • line_total_override (Numeric)

    exact line-total amount to use instead of per-unit calculation

Returns:

  • (Numeric)

    the allocated per-unit amount actually applied



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

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
    # Spread the line total back over the units, then FLOOR to the cent.
    #
    # discounted_price is decimal(10,2), so ActiveRecord rounds every assignment
    # half-up anyway — the choice is only which way. It is the headroom a stacked
    # coupon reads through get_base_amount_discounted, so it must never claim more
    # value is left than actually is: half-up on a -39.97 line total over 4 units
    # (0.0075/unit left) reports 0.01/unit = $0.04 against a true $0.03, and the next
    # coupon spends the phantom cent and pushes the line negative. Flooring can only
    # understate, which caps the next coupon slightly low instead of over-discounting.
    # Exact allocation does not depend on this value — MsrpAllocator tracks headroom
    # separately — and the set_line_discounted_price trigger re-derives the column
    # from line_discounts on write.
    per_unit = BigDecimal(line_total_allocated_amount.to_s) / BigDecimal(@line_item.quantity.to_s)
    @line_item.discounted_price =
      (BigDecimal(@line_item.discounted_price.to_s) + per_unit).round(2, BigDecimal::ROUND_FLOOR)
  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



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'app/services/coupon/line_item_discount_allocator.rb', line 80

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 = ("Discount capped at #{max_discount}% for item #{line_item.sku}" if discount && allowable_discounted_price != new_discounted_price)
    # 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).
  new_allocated_amount = -line_item.discounted_price if new_allocated_amount < 0 && (line_item.discounted_price + new_allocated_amount) < 0

  new_allocated_amount
end

#prep_line_discount(discount) ⇒ Object

Prepare a new or existing line discount



104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'app/services/coupon/line_item_discount_allocator.rb', line 104

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