Back to Spree

Returns, Exchanges & Claims as First-Class Entities

docs/plans/6.0-returns-exchanges-claims.md

5.4.222.3 KB
Original Source

Returns, Exchanges & Claims as First-Class Entities

Status: Draft Target: Spree 6.0 Depends on: Fulfillment & Delivery rework (6.0-fulfillment-and-delivery.md), Split Adjustments (6.0-split-adjustments.md), Normalize state→status (6.0-normalize-state-to-status.md) Author: Damian + Claude Last updated: 2026-03-22

Summary

Replace the deeply nested return/reimbursement chain (ReturnAuthorization → ReturnItem → CustomerReturn → Reimbursement → ReimbursementType → Refund/Exchange) with three first-class entities that directly model the business operations:

  • Return — customer sends items back, gets a refund
  • Exchange — customer sends items back, gets different items
  • Claim — customer reports a problem (damaged, missing, wrong item), may keep the original

Each is a top-level model on Order with its own line items, status, and resolution. No more 6-model-deep nesting. No more exchanges-as-a-reimbursement-type hack.

Problem

1. Six models for a simple return

Today's flow: ReturnAuthorizationReturnItemCustomerReturnReimbursementReimbursementTypeRefund. A merchant processing a return touches 6 interconnected models. Each has its own state machine. The mental model is impossible for new developers.

2. Exchanges are not a first-class concept

An exchange is modeled as a ReturnItem with an exchange_variant_id, processed through ReimbursementType::Exchange. There's no Exchange model — you can't query "all exchanges this month" or "exchanges pending fulfillment" without digging through ReturnItems that happen to have an exchange_variant set.

3. Claims don't exist

If a customer receives a damaged item, there's no way to model "we're sending a replacement without requiring the original back." Today merchants either create a manual order or hack it through the return flow (create a return authorization, immediately mark as received, create an exchange). There's no Claim entity.

4. CustomerReturn is confusing

CustomerReturn represents "physical receipt of returned items at the warehouse." It's the bridge between ReturnAuthorization (permission) and Reimbursement (money). But it duplicates validation logic from ReturnItem, has no state machine, and its order method derives the order from return_items → inventory_units — not a direct association.

5. ReimbursementType STI is over-engineered

Four STI subtypes (Credit, Exchange, OriginalPayment, StoreCredit) that each implement self.reimburse. The Exchange subtype delegates to Spree::Exchange. The OriginalPayment subtype delegates to Refund. The indirection adds complexity without flexibility — the actual reimbursement logic lives in Refund and Exchange, not in the type classes.

Current State

ModelPurposeState machine?Lines
ReturnAuthorizationPermission to returnYes (authorized → canceled)~110
ReturnItemIndividual item in a returnYes (reception_status + acceptance_status)~290
CustomerReturnPhysical receipt at warehouseNo~95
ReimbursementMoney-back executionYes (pending → reimbursed/errored)~170
ReimbursementTypeSTI: how to reimburseNo (strategy class)~20 + 4 subtypes
RefundActual payment reversalNo (after_create: perform!)~140

Total: ~825 lines across 6+ models for return handling.

Key Decisions (do not deviate without discussion)

  • Three first-class models: Return, Exchange, Claim. Each belongs directly to Order.
  • Each has its own line items: ReturnItem, ExchangeItem, ClaimItem. No shared polymorphic item model — each type has different fields.
  • Simple status flow on each. One state machine per entity, not layered state machines across multiple models.
  • Refund stays. Spree::Refund is the payment-level reversal — it stays as-is. Returns and Claims link to Refunds when money needs to go back.
  • Drop: CustomerReturn, Reimbursement, ReimbursementType (all four subtypes), ReturnAuthorization. Their functionality is absorbed into the three new models.
  • ReturnItem is reused (renamed from the current model, simplified). ExchangeItem and ClaimItem are new.

Design Details

Return

A customer returns items and gets a refund (to original payment, store credit, or gift card).

ruby
class Spree::Return < Spree.base_class
  has_prefix_id :ret

  include Spree::Core::NumberGenerator.new(prefix: 'RET', length: 9)
  include Spree::NumberIdentifier
  include Spree::Metafields
  include Spree::Metadata

  publishes_lifecycle_events

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :returns
  belongs_to :stock_location, class_name: 'Spree::StockLocation'
  belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true
  belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true

  has_many :return_items, class_name: 'Spree::ReturnItem', dependent: :destroy, inverse_of: :return
  has_many :refunds, class_name: 'Spree::Refund', as: :originator

  validates :order, :stock_location, presence: true
  validates :return_items, presence: true

  accepts_nested_attributes_for :return_items, allow_destroy: true

  state_machine :status, initial: :requested do
    event :approve do
      transition from: :requested, to: :approved
    end
    event :receive do
      transition from: :approved, to: :received
    end
    event :refund do
      transition from: :received, to: :refunded
    end
    event :cancel do
      transition from: [:requested, :approved], to: :canceled
    end

    after_transition to: :received, do: :restock_items
    after_transition to: :refunded, do: :process_refund
  end

  # Total refund amount
  def refund_total
    return_items.sum(:pre_tax_amount)
  end

  private

  def restock_items
    return_items.each do |item|
      next unless item.resellable?

      stock_location.restock(item.variant, item.quantity)
    end
  end

  def process_refund
    # Create Refund against the order's payment
    payment = order.payments.completed.last
    return unless payment

    Spree::Refund.create!(
      payment: payment,
      amount: refund_total,
      reason: Spree::RefundReason.return_processing,
      originator: self
    )
  end
end

ReturnItem (simplified)

ruby
class Spree::ReturnItem < Spree.base_class
  has_prefix_id :ri

  belongs_to :return, class_name: 'Spree::Return', inverse_of: :return_items
  belongs_to :fulfillment_item, class_name: 'Spree::FulfillmentItem'  # renamed from InventoryUnit
  belongs_to :line_item, class_name: 'Spree::LineItem'
  belongs_to :variant, class_name: 'Spree::Variant'

  validates :fulfillment_item, :quantity, presence: true
  validates :quantity, numericality: { greater_than: 0 }

  attribute :pre_tax_amount, :decimal, default: 0
  attribute :resellable, :boolean, default: true
  attribute :quantity, :integer, default: 1

  before_create :set_default_pre_tax_amount

  delegate :order, to: :return

  private

  def set_default_pre_tax_amount
    self.pre_tax_amount ||= line_item.pre_tax_amount / line_item.quantity * quantity
  end
end

Exchange

A customer returns items and gets different items in return (different size, different product).

ruby
class Spree::Exchange < Spree.base_class
  has_prefix_id :exch

  include Spree::Core::NumberGenerator.new(prefix: 'EX', length: 9)
  include Spree::NumberIdentifier
  include Spree::Metafields
  include Spree::Metadata

  publishes_lifecycle_events

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :exchanges
  belongs_to :stock_location, class_name: 'Spree::StockLocation'
  belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true
  belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true

  has_many :exchange_items, class_name: 'Spree::ExchangeItem', dependent: :destroy, inverse_of: :exchange
  has_one :exchange_fulfillment, class_name: 'Spree::Fulfillment', as: :originator  # the new shipment

  # If there's a price difference, link to a refund or additional payment
  has_many :refunds, class_name: 'Spree::Refund', as: :originator

  validates :order, :stock_location, presence: true
  validates :exchange_items, presence: true

  accepts_nested_attributes_for :exchange_items, allow_destroy: true

  state_machine :status, initial: :requested do
    event :approve do
      transition from: :requested, to: :approved
    end
    event :receive do
      transition from: :approved, to: :received
    end
    event :fulfill do
      transition from: :received, to: :fulfilled
    end
    event :cancel do
      transition from: [:requested, :approved], to: :canceled
    end

    after_transition to: :received, do: :restock_returned_items
    after_transition to: :fulfilled, do: :create_exchange_fulfillment
  end

  # Price difference: positive = customer owes more, negative = refund due
  def price_difference
    exchange_items.sum { |ei| ei.new_variant_price - ei.original_price }
  end

  private

  def restock_returned_items
    exchange_items.each do |item|
      next unless item.resellable?

      stock_location.restock(item.original_variant, item.quantity)
    end
  end

  def create_exchange_fulfillment
    # Create a new fulfillment with the exchange variants
    fulfillment = order.fulfillments.create!(
      stock_location: stock_location,
      fulfillment_type: 'shipping',
      address: order.ship_address
    )

    exchange_items.each do |item|
      fulfillment.fulfillment_items.create!(
        variant: item.new_variant,
        quantity: item.quantity,
        order: order,
        line_item: item.line_item
      )
    end
  end
end

ExchangeItem

ruby
class Spree::ExchangeItem < Spree.base_class
  has_prefix_id :ei

  belongs_to :exchange, class_name: 'Spree::Exchange', inverse_of: :exchange_items
  belongs_to :fulfillment_item, class_name: 'Spree::FulfillmentItem'  # the original item being returned
  belongs_to :line_item, class_name: 'Spree::LineItem'  # original line item
  belongs_to :original_variant, class_name: 'Spree::Variant'
  belongs_to :new_variant, class_name: 'Spree::Variant'

  validates :fulfillment_item, :original_variant, :new_variant, :quantity, presence: true
  validates :quantity, numericality: { greater_than: 0 }

  attribute :quantity, :integer, default: 1
  attribute :resellable, :boolean, default: true

  def original_price
    line_item.price * quantity
  end

  def new_variant_price
    new_variant.price_in(exchange.order.currency)&.amount.to_d * quantity
  end
end

Claim

A customer reports a problem — damaged, missing, or wrong item. The merchant may send a replacement, issue a refund, or both — without requiring the original item back.

ruby
class Spree::Claim < Spree.base_class
  has_prefix_id :claim

  include Spree::Core::NumberGenerator.new(prefix: 'CLM', length: 9)
  include Spree::NumberIdentifier
  include Spree::Metafields
  include Spree::Metadata

  publishes_lifecycle_events

  CLAIM_TYPES = %w[damaged missing wrong_item other].freeze

  belongs_to :order, class_name: 'Spree::Order', inverse_of: :claims
  belongs_to :reason, class_name: 'Spree::ClaimReason', optional: true
  belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true

  has_many :claim_items, class_name: 'Spree::ClaimItem', dependent: :destroy, inverse_of: :claim
  has_many :refunds, class_name: 'Spree::Refund', as: :originator
  has_one :replacement_fulfillment, class_name: 'Spree::Fulfillment', as: :originator

  validates :order, :claim_type, presence: true
  validates :claim_items, presence: true
  validates :claim_type, inclusion: { in: CLAIM_TYPES }

  accepts_nested_attributes_for :claim_items, allow_destroy: true

  state_machine :status, initial: :open do
    event :approve do
      transition from: :open, to: :approved
    end
    event :resolve do
      transition from: :approved, to: :resolved
    end
    event :deny do
      transition from: :open, to: :denied
    end
    event :cancel do
      transition from: [:open, :approved], to: :canceled
    end

    after_transition to: :resolved, do: :execute_resolution
  end

  # Resolution type: how the claim is being resolved
  # 'refund', 'replacement', 'refund_and_replacement'
  attribute :resolution, :string

  private

  def execute_resolution
    process_refund if resolution&.include?('refund')
    create_replacement if resolution&.include?('replacement')
  end

  def process_refund
    payment = order.payments.completed.last
    return unless payment

    amount = claim_items.sum(:refund_amount)
    return if amount.zero?

    Spree::Refund.create!(
      payment: payment,
      amount: amount,
      reason: Spree::RefundReason.claim_processing,
      originator: self
    )
  end

  def create_replacement
    stock_location = Spree::StockLocation.default
    fulfillment = order.fulfillments.create!(
      stock_location: stock_location,
      fulfillment_type: 'shipping',
      address: order.ship_address
    )

    claim_items.where(send_replacement: true).each do |item|
      fulfillment.fulfillment_items.create!(
        variant: item.replacement_variant || item.variant,
        quantity: item.quantity,
        order: order,
        line_item: item.line_item
      )
    end
  end
end

ClaimItem

ruby
class Spree::ClaimItem < Spree.base_class
  has_prefix_id :ci

  belongs_to :claim, class_name: 'Spree::Claim', inverse_of: :claim_items
  belongs_to :line_item, class_name: 'Spree::LineItem'
  belongs_to :variant, class_name: 'Spree::Variant'
  belongs_to :replacement_variant, class_name: 'Spree::Variant', optional: true

  # Optional images of damage
  has_many_attached :images

  validates :line_item, :variant, :quantity, presence: true
  validates :quantity, numericality: { greater_than: 0 }

  attribute :quantity, :integer, default: 1
  attribute :send_replacement, :boolean, default: false
  attribute :refund_amount, :decimal, default: 0
  attribute :description, :text  # customer's description of the problem
end

Order associations

ruby
class Spree::Order < Spree.base_class
  has_many :returns, class_name: 'Spree::Return', dependent: :destroy
  has_many :exchanges, class_name: 'Spree::Exchange', dependent: :destroy
  has_many :claims, class_name: 'Spree::Claim', dependent: :destroy

  # Remove:
  # has_many :return_authorizations
  # has_many :reimbursements
end

Refund gains polymorphic originator

ruby
class Spree::Refund < Spree.base_class
  # Existing: belongs_to :payment
  # Add: polymorphic originator — who triggered this refund?
  belongs_to :originator, polymorphic: true, optional: true
  # originator can be: Spree::Return, Spree::Exchange, Spree::Claim, or nil (manual refund)
end

Note: this is the one intentional polymorphic association — Refund needs to know what triggered it, and the originator set is small and closed (3 types + nil). The performance concern from Adjustments doesn't apply here because refunds are infrequent and never bulk-queried in hot paths.

Migration Path

Phase 1: Create new tables

ruby
class CreateReturnsExchangesClaims < ActiveRecord::Migration[7.2]
  def change
    create_table :spree_returns do |t|
      t.string :number, null: false
      t.references :order, null: false
      t.references :stock_location, null: false
      t.references :reason
      t.references :created_by
      t.string :status, null: false, default: 'requested'
      t.text :memo
      t.jsonb :public_metadata
      t.jsonb :private_metadata
      t.timestamps
    end
    add_index :spree_returns, :number, unique: true

    create_table :spree_exchanges do |t|
      t.string :number, null: false
      t.references :order, null: false
      t.references :stock_location, null: false
      t.references :reason
      t.references :created_by
      t.string :status, null: false, default: 'requested'
      t.text :memo
      t.jsonb :public_metadata
      t.jsonb :private_metadata
      t.timestamps
    end
    add_index :spree_exchanges, :number, unique: true

    create_table :spree_claims do |t|
      t.string :number, null: false
      t.references :order, null: false
      t.references :reason
      t.references :created_by
      t.string :status, null: false, default: 'open'
      t.string :claim_type, null: false
      t.string :resolution
      t.text :memo
      t.jsonb :public_metadata
      t.jsonb :private_metadata
      t.timestamps
    end
    add_index :spree_claims, :number, unique: true

    # Rename old return_items to legacy, create new simplified table
    rename_table :spree_return_items, :spree_return_items_legacy

    create_table :spree_return_items do |t|
      t.references :return, null: false
      t.references :fulfillment_item, null: false
      t.references :line_item, null: false
      t.references :variant, null: false
      t.integer :quantity, null: false, default: 1
      t.decimal :pre_tax_amount, precision: 10, scale: 2, null: false, default: 0
      t.boolean :resellable, null: false, default: true
      t.timestamps
    end

    create_table :spree_exchange_items do |t|
      t.references :exchange, null: false
      t.references :fulfillment_item, null: false
      t.references :line_item, null: false
      t.references :original_variant, null: false
      t.references :new_variant, null: false
      t.integer :quantity, null: false, default: 1
      t.boolean :resellable, null: false, default: true
      t.timestamps
    end

    create_table :spree_claim_items do |t|
      t.references :claim, null: false
      t.references :line_item, null: false
      t.references :variant, null: false
      t.references :replacement_variant
      t.integer :quantity, null: false, default: 1
      t.boolean :send_replacement, null: false, default: false
      t.decimal :refund_amount, precision: 10, scale: 2, null: false, default: 0
      t.text :description
      t.timestamps
    end

    # Refund: add polymorphic originator
    add_column :spree_refunds, :originator_type, :string
    add_column :spree_refunds, :originator_id, :bigint
    add_index :spree_refunds, [:originator_type, :originator_id]
  end
end

Phase 2: Data migration (rake task)

ruby
# rake spree:migrate_returns
#
# For each ReturnAuthorization + its ReturnItems:
#   If any ReturnItem has exchange_variant_id:
#     → Create Exchange with ExchangeItems
#   Else:
#     → Create Return with ReturnItems (new table)
#
# For each Reimbursement:
#   Link its Refunds to the new Return/Exchange via originator
#
# Claims: no migration needed (didn't exist before)
#
# ReturnAuthorizationReason → ReturnReason (rename or alias)
# RefundReason stays as-is
# New: ClaimReason

Phase 3: Model migration

  • Create Return, Exchange, Claim models + line items
  • Update Order associations
  • Update Refund with originator
  • Create ReturnReason, ClaimReason models (or reuse existing reason tables)
  • Update FulfillmentItem (was InventoryUnit) to remove return-specific methods
  • Wire into events system (lifecycle events for each entity)

Phase 4: Drop legacy models

  • Drop: ReturnAuthorization, CustomerReturn, Reimbursement, ReimbursementType (all subtypes)
  • Drop: spree_return_authorizations, spree_customer_returns, spree_reimbursements, spree_reimbursement_types
  • Keep: spree_return_items_legacy (renamed in Phase 1) for historical reference, drop in 6.1
  • Drop: Spree::Exchange service class (replaced by model)

Phase 5: API

# Returns
POST   /api/v3/admin/orders/:order_id/returns
GET    /api/v3/admin/orders/:order_id/returns
PATCH  /api/v3/admin/returns/:id
DELETE /api/v3/admin/returns/:id
POST   /api/v3/admin/returns/:id/approve
POST   /api/v3/admin/returns/:id/receive
POST   /api/v3/admin/returns/:id/refund

# Exchanges
POST   /api/v3/admin/orders/:order_id/exchanges
GET    /api/v3/admin/orders/:order_id/exchanges
PATCH  /api/v3/admin/exchanges/:id
POST   /api/v3/admin/exchanges/:id/approve
POST   /api/v3/admin/exchanges/:id/receive
POST   /api/v3/admin/exchanges/:id/fulfill

# Claims
POST   /api/v3/admin/orders/:order_id/claims
GET    /api/v3/admin/orders/:order_id/claims
PATCH  /api/v3/admin/claims/:id
POST   /api/v3/admin/claims/:id/approve
POST   /api/v3/admin/claims/:id/resolve

# Store API: customers can initiate returns/claims
POST   /api/v3/store/orders/:order_id/returns
POST   /api/v3/store/orders/:order_id/claims
GET    /api/v3/store/orders/:order_id/returns
GET    /api/v3/store/orders/:order_id/claims

Constraints on Current Work

  • Don't add new features to ReturnAuthorization, CustomerReturn, or Reimbursement. They're being replaced.
  • Don't add new ReimbursementType subtypes. The STI hierarchy is being dropped.
  • New return/exchange logic should be designed for the three-model pattern. Think "Return with ReturnItems" not "ReturnAuthorization with ReturnItems through CustomerReturn."
  • Refund stays as-is. It's the payment-level primitive. Returns, Exchanges, and Claims create Refunds when money needs to go back.

Resolved Questions

  1. Store API return initiation. Both Store API (self-service) and Admin API. Customers can initiate returns and claims; exchanges may require admin approval depending on store config. Self-service returns are the modern standard — backoffice-only is legacy.

  2. Return shipping labels. Yes. Return gains generate_label which delegates to the FulfillmentProvider interface (from 6.0-fulfillment-and-delivery.md). E.g., return.generate_label calls EasyPost to create a prepaid return label. The label URL is stored on the Return record. Add return_label_url column.

  3. Partial returns/exchanges. No separate "case" or "ticket" model. All three entities belong to Order — order.returns, order.exchanges, order.claims gives the full picture. The admin UI shows a unified "Post-sale" tab on the order page listing all three chronologically. If correlation is needed (e.g., "this claim was filed because the exchange arrived damaged"), use the metadata JSON column.

Open Questions

None at this time.

References

  • Current return models: spree/core/app/models/spree/return_authorization.rb, return_item.rb, customer_return.rb, reimbursement.rb
  • Current ReimbursementType hierarchy: spree/core/app/models/spree/reimbursement_type/
  • Related plan: 6.0-fulfillment-and-delivery.md (FulfillmentItem replaces InventoryUnit, Fulfillment replaces Shipment)
  • Related plan: 6.0-split-adjustments.md (return credits removed from adjustment system)
  • Related plan: 6.0-typed-stock-movements.md (restock on return creates received movement)