Class: Coupon::ItemizableDiscountCalculator
- Inherits:
-
Object
- Object
- Coupon::ItemizableDiscountCalculator
- 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
-
#active_lines ⇒ Object
readonly
Returns the value of attribute active_lines.
-
#customer ⇒ Object
readonly
Returns the value of attribute customer.
-
#itemizable ⇒ Object
readonly
Returns the value of attribute itemizable.
-
#logger ⇒ Object
readonly
Returns the value of attribute logger.
-
#options ⇒ Object
readonly
Returns the value of attribute options.
-
#qualifier ⇒ Object
readonly
Returns the value of attribute qualifier.
Instance Method Summary collapse
-
#calculate ⇒ Object
Main Method, responsible for running the calculations on an itemizable, apply auto coupons, and calculate them.
-
#initialize(itemizable, options = {}) ⇒ ItemizableDiscountCalculator
constructor
A new instance of ItemizableDiscountCalculator.
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, = {}) @logger = [:logger] || Rails.logger @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_lines ⇒ Object (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 |
#customer ⇒ Object (readonly)
Returns the value of attribute customer.
5 6 7 |
# File 'app/services/coupon/itemizable_discount_calculator.rb', line 5 def customer @customer end |
#itemizable ⇒ Object (readonly)
Returns the value of attribute itemizable.
5 6 7 |
# File 'app/services/coupon/itemizable_discount_calculator.rb', line 5 def itemizable @itemizable end |
#logger ⇒ Object (readonly)
Returns the value of attribute logger.
5 6 7 |
# File 'app/services/coupon/itemizable_discount_calculator.rb', line 5 def logger @logger end |
#options ⇒ Object (readonly)
Returns the value of attribute options.
5 6 7 |
# File 'app/services/coupon/itemizable_discount_calculator.rb', line 5 def @options end |
#qualifier ⇒ Object (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
#calculate ⇒ Object
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 |