docs/plans/6.0-split-adjustments.md
Status: Draft Target: Spree 6.0 Depends on: None (standalone) Author: Damian + Claude Last updated: 2026-03-16
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.
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.
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.
The same spree_adjustments row represents:
Scopes like tax, promotion, shipping, price all filter by string-matching source_type or adjustable_type — unindexed class name comparisons.
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.
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.
| What | Where |
|---|---|
spree_adjustments table | 1 table, ~15 columns |
Spree::Adjustment model | 130 lines |
AdjustmentsUpdater | Orchestrates all recalculation |
| 3 Adjusters | Tax, Promotion, Base |
PromotionAccumulator | Best-promo selection |
AdjustmentSource concern | Included by TaxRate, 3 PromotionActions |
| Order, LineItem, Shipment | All have has_many :adjustments + 6 denormalized total columns each |
Three typed models replace one polymorphic model:
Spree::TaxLine — tax charges, concrete FK to tax_rate_idSpree::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 sourceConcrete 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.
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
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
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
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
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
The AdjustmentsUpdater is replaced by typed updaters that can eager-load their sources:
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
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
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
Instead of writing all competing promo adjustments and then marking losers as eligible: false, we compute all candidates and only persist the winner:
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.
Custom adjustment types register as adjusters and write to Fee:
# 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:
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
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
# 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)
Spree::Adjustment with TaxLine, Discount, FeeAdjustmentSource concernCalculatedAdjustments concern usage for adjustmentsOrderUpdater to use typed queriesTaxRate.adjust to write TaxLine recordsPromotionAction subclasses to write Discount recordsAdjustable::AdjustmentsUpdater, Adjustable::Adjuster::*, PromotionAccumulatoradjustment responses become typed: tax_lines, discounts, fees in serializerstax_lines and discountsspree_adjustments tableSpree::Adjustment modelsource_type string matching. New code should use the tax, promotion scopes which will be replaced by typed table queries.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.
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.
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.
None at this time.
spree/core/app/models/spree/adjustment.rbspree/core/app/models/spree/adjustable/adjustments_updater.rbspree/core/app/models/spree/order_updater.rbAdjustmentSource concern: spree/core/app/models/concerns/spree/adjustment_source.rb6.0-cart-order-split.md