docs/plans/6.0-returns-exchanges-claims.md
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
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:
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.
Today's flow: ReturnAuthorization → ReturnItem → CustomerReturn → Reimbursement → ReimbursementType → Refund. A merchant processing a return touches 6 interconnected models. Each has its own state machine. The mental model is impossible for new developers.
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.
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.
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.
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.
| Model | Purpose | State machine? | Lines |
|---|---|---|---|
| ReturnAuthorization | Permission to return | Yes (authorized → canceled) | ~110 |
| ReturnItem | Individual item in a return | Yes (reception_status + acceptance_status) | ~290 |
| CustomerReturn | Physical receipt at warehouse | No | ~95 |
| Reimbursement | Money-back execution | Yes (pending → reimbursed/errored) | ~170 |
| ReimbursementType | STI: how to reimburse | No (strategy class) | ~20 + 4 subtypes |
| Refund | Actual payment reversal | No (after_create: perform!) | ~140 |
Total: ~825 lines across 6+ models for return handling.
Return, Exchange, Claim. Each belongs directly to Order.ReturnItem, ExchangeItem, ClaimItem. No shared polymorphic item model — each type has different fields.Spree::Refund is the payment-level reversal — it stays as-is. Returns and Claims link to Refunds when money needs to go back.CustomerReturn, Reimbursement, ReimbursementType (all four subtypes), ReturnAuthorization. Their functionality is absorbed into the three new models.A customer returns items and gets a refund (to original payment, store credit, or gift card).
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
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
A customer returns items and gets different items in return (different size, different product).
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
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
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.
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
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
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
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.
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
# 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
spree_return_authorizations, spree_customer_returns, spree_reimbursements, spree_reimbursement_typesspree_return_items_legacy (renamed in Phase 1) for historical reference, drop in 6.1Spree::Exchange service class (replaced by model)# 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
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.
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.
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.
None at this time.
spree/core/app/models/spree/return_authorization.rb, return_item.rb, customer_return.rb, reimbursement.rbspree/core/app/models/spree/reimbursement_type/6.0-fulfillment-and-delivery.md (FulfillmentItem replaces InventoryUnit, Fulfillment replaces Shipment)6.0-split-adjustments.md (return credits removed from adjustment system)6.0-typed-stock-movements.md (restock on return creates received movement)