Class: Coupon::ItemizableDiscountCalculator

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

Overview

This class is responsible for calculating and applying coupons
to an itemizable

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(itemizable, options = {}) ⇒ ItemizableDiscountCalculator

Returns a new instance of ItemizableDiscountCalculator.



7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# File 'app/services/coupon/itemizable_discount_calculator.rb', line 7

def initialize(itemizable, options = {})
  @logger = options[:logger] || Rails.logger
  @options = options
  @itemizable = itemizable
  @customer = @itemizable.customer
  @qualifier = Coupon::Qualifier.new
  # When the itemizable or its line items are unsaved (new_record?), we cannot
  # persist discounts/line_discounts independently — the parent's autosave chain
  # will handle it. This happens during order splits and synchronize_lines.
  @deferred_persistence = @itemizable.new_record? || @itemizable.line_items.any?(&:new_record?)
  # Force reload of line_items to ensure we have the latest shipping lines
  # after retrieve_shipping_costs has destroyed/recreated them
  # Eager load critical associations to prevent N+1 queries
  # BUT don't reset if there are unsaved line items (they would be lost!)
  if @itemizable.line_items.loaded? && @itemizable.line_items.none?(&:new_record?)
    @itemizable.line_items.reset
  end
  # @active_lines is now set in calculate() with preloads
end

Instance Attribute Details

#active_linesObject (readonly)

Returns the value of attribute active_lines.



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

def active_lines
  @active_lines
end

#customerObject (readonly)

Returns the value of attribute customer.



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

def customer
  @customer
end

#itemizableObject (readonly)

Returns the value of attribute itemizable.



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

def itemizable
  @itemizable
end

#loggerObject (readonly)

Returns the value of attribute logger.



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

def logger
  @logger
end

#optionsObject (readonly)

Returns the value of attribute options.



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

def options
  @options
end

#qualifierObject (readonly)

Returns the value of attribute qualifier.



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

def qualifier
  @qualifier
end

Instance Method Details

#calculateObject

Main Method, responsible for running the calculations
on an itemizable, apply auto coupons, and calculate them



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

def calculate
  # Cleanup — skip for unsaved itemizables (no persisted data to clean up)
  cleanup_invalid_line_discounts unless @deferred_persistence

  # Set @active_lines BEFORE checking if empty, to ensure we have fresh line items
  # Preload associations to prevent N+1 queries during discount calculations
  # BUT if there are unsaved line items, use the in-memory collection directly
  # (querying the database would miss them and incorrectly show empty cart)
  @active_lines = load_active_lines

  # same if no line items.
  if active_lines.empty?
    logger.warn 'No line items to process, deleting discounts and returning'
    @itemizable.discounts.destroy_all unless @deferred_persistence
    return true
  end

  # Apply tier2 pricing
  logger.debug 'Applying tier 2 pricing'
  tpp = apply_tier2_pricing
  logger.debug "Tier 2 pricing applied: #{tpp.inspect}"

  logger.debug 'Removing invalid coupons'
  removed_discount_ids = remove_invalid_coupons

  # Reload discounts to exclude any that were destroyed in remove_invalid_coupons
  # MUST happen BEFORE apply_auto_coupons so new discounts aren't wiped out
  # @itemizable.discounts.reload
  logger.debug 'Applying auto coupons'
  # Check for any auto apply discounts, pass in the previously invalid coupon ids so we don't bother checking them again
  apply_auto_coupons(exclude_coupon_ids: removed_discount_ids)

  # Re-set @active_lines to ensure we have fresh line items after any auto-coupon changes or shipping recalculation
  # This is critical for F3-A and other shipping discounts after reset_discount
  @active_lines = load_active_lines

  # Reset all discounts amount in line items
  active_lines.each do |li|
    li.discounted_price = li.price # Start at MSRP
  end

  # iterate through each discounts in order of priority and calculate
  dc_list = if @deferred_persistence
              @itemizable.discounts.select(&:coupon).sort_by { |disc| [disc.coupon.type, disc.coupon.position] }
            else
              persisted_discounts = @itemizable.discounts.includes(coupon: :product_filters, line_discounts: :line_item).to_a
              new_discounts = @itemizable.discounts.select(&:new_record?)
              (persisted_discounts + new_discounts).uniq.select(&:coupon).sort_by { |disc| [disc.coupon.type, disc.coupon.position] }
            end
  dc_list.each do |discount|
    discount.amount = 0.0
    logger.info "Processing discount id:#{discount.id} for coupon code #{discount.coupon.code}"
    process_discount(discount)
  end

  # Persist discounted_price changes and recalculate tax for all line items.
  # This is necessary because Order's line_items association doesn't have autosave: true,
  # so saving the order alone won't persist line item changes made during discount calculation.
  #
  # CRITICAL: We must reload line_discounts and recalculate tax for ALL line items,
  # not just those where li.changed? is true. Here's why:
  #
  # 1. line_discounts were preloaded at the start of calculate() but new line_discounts
  #    were created/saved during process_discount(). The preloaded association is stale.
  #
  # 2. If discounted_price goes from $0 → $16.99 → $0 (reset then discount applied),
  #    Rails' changed? returns false (no net change), but tax still needs recalculation
  #    because the line_discounts changed.
  #
  # 3. The before_update callback calculates tax using taxable_total which relies on
  #    line_discounts.map(&:amount).sum - this uses stale data without reload.
  #
  # This was causing issues like $1.72 tax on $0 shipping (free shipping with discount)
  # because tax was calculated on pre-discount amount.
  active_lines.each do |li|
    if @deferred_persistence
      # Unsaved line items — update in-memory discounted_price only;
      # the parent's autosave chain will persist everything.
      next
    end

    # Always reload line_discounts to get fresh data from database
    li.line_discounts.reload

    # Recalculate tax using fresh line_discounts data
    # This ensures tax is based on post-discount amount (taxable_total uses discounted_total
    # which now correctly includes the reloaded line_discounts)
    if li.tax_rate.present?
      new_tax_total = (li.taxable_total.to_f * li.tax_rate).round(2)
      li.tax_total = new_tax_total if li.tax_total != new_tax_total
    end

    li.save! if li.changed?
  end
end