Back to Spree

Split Adjustments into Typed Tables

docs/plans/6.0-split-adjustments.md

5.4.218.5 KB
Original Source

Split Adjustments into Typed Tables

Status: Draft Target: Spree 6.0 Depends on: None (standalone) Author: Damian + Claude Last updated: 2026-03-16

Summary

Replace the polymorphic Spree::Adjustment model with three typed models: TaxLine, Discount, and Fee. Each has concrete foreign keys to its source (no polymorphic source_type/source_id), enabling eager loading and eliminating the N+1 queries that make the current adjustment system slow.

The Spree.adjusters extensibility pattern is preserved — developers register custom adjusters that write to the appropriate typed table.

Problem

1. Polymorphic source prevents eager loading

Adjustment has belongs_to :source, polymorphic: true where source can be TaxRate, PromotionAction, or ReturnAuthorization. Rails cannot includes(:source) on a polymorphic association — every adjustment.source triggers a lazy load. With 12-15 adjustments per order, each recalculation does 12-15 individual source queries.

2. Polymorphic adjustable adds complexity

belongs_to :adjustable, polymorphic: true (LineItem or Shipment) means you can't do a simple JOIN from line items to their adjustments without going through all_adjustments on the order.

3. One table does too many jobs

The same spree_adjustments row represents:

  • Tax (from TaxRate, may be included or additional)
  • Promotions (from PromotionAction, competes with other promos)
  • Shipping discounts (from FreeShipping PromotionAction)
  • Manual adjustments (no source)
  • Return credits (from ReturnAuthorization)

Scopes like tax, promotion, shipping, price all filter by string-matching source_type or adjustable_type — unindexed class name comparisons.

4. Recalculation is N+1 heavy

AdjustmentsUpdater iterates each adjustment and calls adjustment.update!source.compute_amount(target) → loads the source → for promotions, loads source.promotion → checks promotion.eligible?(target). Each step is a lazy load.

5. State machine is unnecessary

The open/closed state machine exists to freeze adjustments on completed orders. But order completion already locks the order — per-adjustment state is redundant bookkeeping.

Current blast radius

WhatWhere
spree_adjustments table1 table, ~15 columns
Spree::Adjustment model130 lines
AdjustmentsUpdaterOrchestrates all recalculation
3 AdjustersTax, Promotion, Base
PromotionAccumulatorBest-promo selection
AdjustmentSource concernIncluded by TaxRate, 3 PromotionActions
Order, LineItem, ShipmentAll have has_many :adjustments + 6 denormalized total columns each

Key Decisions (do not deviate without discussion)

  • Three typed models replace one polymorphic model:

    • Spree::TaxLine — tax charges, concrete FK to tax_rate_id
    • Spree::Discount — promotions and manual discounts, concrete FK to promotion_action_id (nullable for manual)
    • Spree::Fee — extensible charges (custom fees, surcharges, future use), no polymorphic source
  • Concrete FKs to line_item and shipment (not polymorphic adjustable). TaxLines and Discounts belong directly to either a line_item or a shipment via separate nullable FKs. This enables simple includes(:tax_lines) on LineItem.

  • All three models belong to order directly — for order-level queries (order.tax_lines, order.discounts).

  • Remove the state machine. No open/closed states. Adjustments on completed orders are immutable by convention (enforced at the service layer, not per-row state).

  • Remove eligible flag. The "best promo wins" logic writes only the winning discount. Losers are not stored as ineligible rows — they simply don't exist. Simpler data, fewer writes.

  • Keep Spree.adjusters registry for extensibility. Custom adjusters write to Fee (or Discount for custom discount types). Registration pattern unchanged.

  • Denormalized totals on LineItem/Shipment/Order stay. The included_tax_total, additional_tax_total, promo_total, adjustment_total columns remain — they're the performance optimization. But they're now computed from typed tables, not from polymorphic filter scopes.

Design Details

TaxLine

ruby
class Spree::TaxLine < Spree.base_class
  has_prefix_id :tl

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :tax_lines
  belongs_to :tax_rate, class_name: 'Spree::TaxRate'

  # Concrete adjustable — one of these is set, not both
  belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
  belongs_to :shipment, class_name: 'Spree::Shipment', optional: true

  validates :amount, numericality: true
  validates :label, presence: true
  validate :exactly_one_adjustable

  # Replaces adjustment.included? — tax can be included in price or additional
  attribute :included, :boolean, default: false

  scope :included_in_price, -> { where(included: true) }
  scope :additional, -> { where(included: false) }
  scope :for_line_items, -> { where.not(line_item_id: nil) }
  scope :for_shipments, -> { where.not(shipment_id: nil) }

  def adjustable
    line_item || shipment
  end

  private

  def exactly_one_adjustable
    if line_item_id.blank? && shipment_id.blank?
      errors.add(:base, 'must belong to a line item or shipment')
    elsif line_item_id.present? && shipment_id.present?
      errors.add(:base, 'cannot belong to both a line item and shipment')
    end
  end
end

Discount

ruby
class Spree::Discount < Spree.base_class
  has_prefix_id :disc

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :discounts
  belongs_to :promotion_action, class_name: 'Spree::PromotionAction', optional: true
  belongs_to :promotion, class_name: 'Spree::Promotion', optional: true

  # Concrete adjustable — always one of these (order-level discounts distributed to line items)
  belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
  belongs_to :shipment, class_name: 'Spree::Shipment', optional: true

  validates :amount, numericality: true  # always negative (credit)
  validates :label, presence: true
  validate :exactly_one_adjustable

  scope :for_line_items, -> { where.not(line_item_id: nil) }
  scope :for_shipments, -> { where.not(shipment_id: nil) }
  scope :automatic, -> { joins(:promotion).where(spree_promotions: { kind: 'automatic' }) }
  scope :manual, -> { where(promotion_action_id: nil) }

  def adjustable
    line_item || shipment
  end

  def promotion?
    promotion_action_id.present?
  end

  def manual?
    promotion_action_id.nil?
  end
end

Fee (extensible)

ruby
class Spree::Fee < Spree.base_class
  has_prefix_id :fee

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :fees

  # Concrete adjustable
  belongs_to :line_item, class_name: 'Spree::LineItem', optional: true
  belongs_to :shipment, class_name: 'Spree::Shipment', optional: true

  validates :amount, numericality: { greater_than_or_equal_to: 0 }
  validates :label, presence: true
  validates :kind, presence: true  # e.g., 'surcharge', 'handling', 'gift_wrap'

  scope :for_line_items, -> { where.not(line_item_id: nil) }
  scope :for_shipments, -> { where.not(shipment_id: nil) }

  def adjustable
    line_item || shipment
  end
end

Order associations

ruby
class Spree::Order < Spree.base_class
  has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
  has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy
  has_many :fees, class_name: 'Spree::Fee', dependent: :destroy

  # Convenience through-associations
  has_many :line_item_tax_lines, through: :line_items, source: :tax_lines
  has_many :line_item_discounts, through: :line_items, source: :discounts
  has_many :shipment_tax_lines, through: :shipments, source: :tax_lines
  has_many :shipment_discounts, through: :shipments, source: :discounts
end

LineItem / Shipment associations

ruby
class Spree::LineItem < Spree.base_class
  has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
  has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy
  has_many :fees, class_name: 'Spree::Fee', dependent: :destroy

  # Denormalized totals stay — recomputed from typed tables
  # included_tax_total, additional_tax_total, promo_total, adjustment_total
end

class Spree::Shipment < Spree.base_class
  has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :destroy
  has_many :discounts, class_name: 'Spree::Discount', dependent: :destroy
  has_many :fees, class_name: 'Spree::Fee', dependent: :destroy
end

Recalculation (new)

The AdjustmentsUpdater is replaced by typed updaters that can eager-load their sources:

ruby
class Spree::OrderUpdater
  def update_adjustment_total
    # Each updater works with concrete associations — no N+1
    update_tax_totals
    update_discount_totals
    update_fee_totals
    update_order_total
  end

  private

  def update_tax_totals
    # Single query: SUM tax_lines grouped by included flag
    line_item_taxes = order.tax_lines.for_line_items.group(:included).sum(:amount)
    shipment_taxes = order.tax_lines.for_shipments.group(:included).sum(:amount)

    order.included_tax_total = (line_item_taxes[true] || 0) + (shipment_taxes[true] || 0)
    order.additional_tax_total = (line_item_taxes[false] || 0) + (shipment_taxes[false] || 0)
  end

  def update_discount_totals
    # Single query
    order.promo_total = order.discounts.sum(:amount)  # always negative
  end

  def update_fee_totals
    order.fee_total = order.fees.sum(:amount)
  end
end

TaxRate (source side changes)

ruby
class Spree::TaxRate < Spree.base_class
  # Remove: include Spree::AdjustmentSource
  has_many :tax_lines, class_name: 'Spree::TaxLine', dependent: :nullify

  def self.adjust(order, items)
    # Bulk compute all tax lines for all items in one pass
    applicable_rates = match(order.tax_zone)
    return if applicable_rates.empty?

    items.each do |item|
      applicable_rates.each do |rate|
        amount = rate.compute_amount(item)
        next if amount.zero?

        item.tax_lines.find_or_initialize_by(tax_rate: rate, order: order).tap do |tl|
          tl.amount = amount
          tl.included = rate.included_in_price?
          tl.label = rate.adjustment_label(amount)
          tl.save!
        end
      end
    end
  end
end

PromotionAction (source side changes)

ruby
class Spree::Promotion::Actions::CreateItemAdjustments < Spree::PromotionAction
  # Remove: include Spree::AdjustmentSource
  has_many :discounts, class_name: 'Spree::Discount', foreign_key: :promotion_action_id

  def perform(order:, **_args)
    # Best-promo logic: compute amount, only write if this is the best
    order.line_items.each do |line_item|
      amount = compute_amount(line_item)
      next if amount.zero?

      line_item.discounts.find_or_initialize_by(
        promotion_action: self,
        promotion: promotion,
        order: order
      ).tap do |disc|
        disc.amount = amount
        disc.label = "#{promotion.name} (#{promotion.code})"
        disc.save!
      end
    end
  end
end

Best-promo selection (simplified)

Instead of writing all competing promo adjustments and then marking losers as eligible: false, we compute all candidates and only persist the winner:

ruby
class Spree::Adjustable::Adjuster::Promotion < Spree::Adjustable::Adjuster::Base
  def update
    candidates = compute_all_promo_amounts(adjustable)
    # candidates: [{ promotion_action: ..., amount: -5.00, label: ... }, ...]

    best = candidates.min_by { |c| c[:amount] }  # most negative = biggest discount
    return unless best

    # Delete any existing non-winning discounts for this adjustable
    adjustable.discounts.where.not(promotion_action_id: best[:promotion_action].id).destroy_all

    # Upsert the winner
    adjustable.discounts.find_or_initialize_by(
      promotion_action: best[:promotion_action],
      order: adjustable.order
    ).update!(amount: best[:amount], label: best[:label], promotion: best[:promotion_action].promotion)
  end
end

No eligible flag, no write-then-update-all. Only the winner is stored.

Extensibility

Custom adjustment types register as adjusters and write to Fee:

ruby
# config/initializers/spree.rb
Spree.adjusters << MyApp::Adjustable::Adjuster::GiftWrap

# app/models/my_app/adjustable/adjuster/gift_wrap.rb
class MyApp::Adjustable::Adjuster::GiftWrap < Spree::Adjustable::Adjuster::Base
  def update
    return unless adjustable.respond_to?(:gift_wrap?) && adjustable.gift_wrap?

    adjustable.fees.find_or_initialize_by(kind: 'gift_wrap', order: adjustable.order).tap do |fee|
      fee.amount = 5.99
      fee.label = 'Gift wrapping'
      fee.save!
    end

    @totals[:non_taxable_adjustment_total] += 5.99
  end
end

For custom discount types, same pattern with Discount:

ruby
class MyApp::Adjustable::Adjuster::LoyaltyDiscount < Spree::Adjustable::Adjuster::Base
  def update
    points = adjustable.order.user&.loyalty_points || 0
    return if points.zero?

    discount_amount = [points * 0.01, adjustable.amount * 0.1].min

    adjustable.discounts.find_or_initialize_by(kind: 'loyalty', order: adjustable.order).tap do |disc|
      disc.amount = -discount_amount
      disc.label = "Loyalty discount (#{points} pts)"
      disc.save!
    end
  end
end

Migration Path

Phase 1: Create new tables

ruby
class CreateTypedAdjustmentTables < ActiveRecord::Migration[7.2]
  def change
    create_table :spree_tax_lines do |t|
      t.references :order, null: false
      t.references :tax_rate, null: false
      t.references :line_item
      t.references :shipment
      t.decimal :amount, precision: 10, scale: 2, null: false
      t.string :label, null: false
      t.boolean :included, null: false, default: false
      t.timestamps
    end

    add_index :spree_tax_lines, [:line_item_id, :tax_rate_id], unique: true,
              where: 'line_item_id IS NOT NULL', name: 'idx_tax_lines_line_item_rate'
    add_index :spree_tax_lines, [:shipment_id, :tax_rate_id], unique: true,
              where: 'shipment_id IS NOT NULL', name: 'idx_tax_lines_shipment_rate'

    create_table :spree_discounts do |t|
      t.references :order, null: false
      t.references :promotion_action
      t.references :promotion
      t.references :line_item
      t.references :shipment
      t.decimal :amount, precision: 10, scale: 2, null: false
      t.string :label, null: false
      t.string :kind  # 'promotion', 'manual', 'loyalty', etc.
      t.timestamps
    end

    add_index :spree_discounts, [:line_item_id, :promotion_action_id], unique: true,
              where: 'line_item_id IS NOT NULL', name: 'idx_discounts_line_item_action'

    create_table :spree_fees do |t|
      t.references :order, null: false
      t.references :line_item
      t.references :shipment
      t.decimal :amount, precision: 10, scale: 2, null: false
      t.string :label, null: false
      t.string :kind, null: false
      t.timestamps
    end

    add_index :spree_fees, [:order_id, :kind]
  end
end

Phase 2: Data migration (rake task)

ruby
# rake spree:migrate_adjustments
#
# For each adjustment in spree_adjustments:
#   source_type == 'Spree::TaxRate'         → create TaxLine
#   source_type == 'Spree::PromotionAction' → create Discount (only eligible ones)
#   source_type == nil (manual)             → create Discount (kind: 'manual')
#   source_type == 'Spree::ReturnAuthorization' → skip (returns handled by Refund model)
#   anything else                           → create Fee
#
# Map adjustable_type:
#   'Spree::LineItem' → set line_item_id
#   'Spree::Shipment' → set shipment_id
#   'Spree::Order'    → distribute proportionally to line_items (order-level discounts)

Phase 3: Model migration

  • Replace Spree::Adjustment with TaxLine, Discount, Fee
  • Remove AdjustmentSource concern
  • Remove CalculatedAdjustments concern usage for adjustments
  • Update OrderUpdater to use typed queries
  • Update TaxRate.adjust to write TaxLine records
  • Update PromotionAction subclasses to write Discount records
  • Remove Adjustable::AdjustmentsUpdater, Adjustable::Adjuster::*, PromotionAccumulator
  • Add new typed adjusters

Phase 4: API + serializer updates

  • adjustment responses become typed: tax_lines, discounts, fees in serializers
  • Order serializer includes totals derived from typed tables
  • LineItem serializer includes nested tax_lines and discounts

Phase 5: Cleanup (6.1)

  • Drop spree_adjustments table
  • Remove Spree::Adjustment model
  • Remove all legacy scopes and methods

Constraints on Current Work

  • Don't add new adjustment source types. New fee/discount types should be designed for the typed model.
  • Don't rely on source_type string matching. New code should use the tax, promotion scopes which will be replaced by typed table queries.
  • Don't add new state machine transitions to Adjustment. The state machine is being removed.
  • Keep denormalized totals on LineItem/Shipment/Order. These stay — they're the performance optimization.

Resolved Questions

  1. Order-level discounts. Order-level promotion amounts are distributed proportionally across line items at application time. No order-level discount records with nil FKs. Every Discount always belongs to a line_item or shipment. This is the industry-standard approach — it simplifies tax calculation (tax applies to the discounted per-item amount), partial refunds (each item knows its actual discount), and accounting. The CreateAdjustment promotion action (order-level) distributes its amount across line items weighted by line_item.amount / order.item_total.

  2. Return credits. Separate domain — return credits are not modeled as Discount. Returns, exchanges, and claims will be addressed as first-class entities in a dedicated 6.0 plan (see: Claims/Exchanges as first-class entities). Return refunds flow through the Refund model and payment system, not through the adjustment/discount system.

  3. fee_total on Order. Add fee_total column to spree_orders. Explicit and consistent with the existing promo_total, shipment_total pattern. Order total formula becomes: total = item_total + shipment_total + fee_total + additional_tax_total + promo_total.

Open Questions

None at this time.

References

  • Current adjustment model: spree/core/app/models/spree/adjustment.rb
  • Current recalculation: spree/core/app/models/spree/adjustable/adjustments_updater.rb
  • Current order updater: spree/core/app/models/spree/order_updater.rb
  • AdjustmentSource concern: spree/core/app/models/concerns/spree/adjustment_source.rb
  • Related plan: 6.0-cart-order-split.md