Spree Returns, Exchanges & Claims
Three separate records, each with its own lifecycle, all hanging off a placed
:
| Record | Prefix / number | What happened | Outcome |
|---|
| / | Customer sends items back | Money back |
| / | Customer sends items back | Different items (price difference settled) |
| / | Arrived damaged, never arrived, wrong item | Refund, replacement, or both — goods usually not returned |
Keep them apart: don't model "arrived smashed" as a return you immediately mark received — use a claim, so damage is reportable. Coming from 5.x (
→
→
,
)? Those are gone; see
spree-upgrade-5-to-6.
Models
Order
├── Return (ret_) status, stock_location, reason (ReturnReason rar_), memo, received_at, refunded_at, created_by
│ ├── ReturnLineItem (rli_) fulfillment_item + line_item + variant; quantity (announced), received_quantity, resellable, pre_tax_amount
│ ├── refunds (Spree::Refund, originator: return) / store_credits (originator: return)
│ ├── ShippingLabel × n prepaid return postage
│ └── Delivery × n inbound tracking
├── Exchange (exch_) status, stock_location, reason (ReturnReason)
│ └── ExchangeLineItem (eli_) fulfillment_item + line_item; original_variant → new_variant; price_difference
└── Claim (claim_) status, resolution, reason (ClaimReason clr_)
└── ClaimLineItem (cli_) line_item + variant; quantity, description, images, send_replacement,
replacement_variant, refund_amount
- Returns and exchanges reference fulfillment items (units that shipped), not cart line items. Claims reference line items (the goods may never have arrived).
- All three are store-scoped (), carry and custom fields, and record who acted (, approver, refunder).
- Reasons are store-owned vocabularies: (used by returns and exchanges) and . Seeds create a default set per store (claim reasons such as "Arrived damaged", "Never arrived", "Wrong item sent"); merchants add their own.
Statuses
String
columns via
— no state machine, transitions are workflows:
| Record | Flow | Cancel allowed from |
|---|
| Return | → → → , or | , |
| Exchange | → → → , or | , |
| Claim | → → , or / | , (deny only from ) |
Workflows
Every transition is a
resolved through
(swap via
, see spree-dependencies). Every one has a leading
hook.
| Record | Workflow (DI key) | Arguments | Hooks |
|---|
| Return | → | order:, items: [{ fulfillment_item:, quantity: }], stock_location:, reason:, memo:, created_by:
| , |
| → | return_record:, approver:
| , |
| → | return_record:, items: [{ return_line_item:, quantity:, resellable: }], received_by:
| , , |
| → | return_record:, amount:, refund_method: 'original_payment', refunder:
| , , |
| → | | , |
| return_purchase_label_workflow
→ | | , |
| Exchange | → | order:, items: [{ fulfillment_item:, new_variant:, quantity: }], …
| , |
| exchange_approve_workflow
, exchange_receive_workflow
, | as returns | , (+ on receive) |
| exchange_fulfill_workflow
→ | exchange:, refund_method: 'store_credit', refunder:
| , , |
| Claim | → | order:, items: [{ line_item:, quantity:, description:, send_replacement:, replacement_variant:, refund_amount: }], reason:, memo:, created_by:
| , |
| , , | | , |
| → | claim:, resolution:, refund_method: 'store_credit', amount:, replacement_line_item_ids:, resolver:
| , , |
Hook keys follow the class name:
,
returns.receive.before_restock
,
exchanges.fulfill.before_settle
,
claims.resolve.after_resolve
, etc.
ruby
result = Spree.return_create_workflow.call(
order: order,
items: [{ fulfillment_item: order.fulfillment_items.find_by_prefix_id!('fi_…'), quantity: 1 }],
reason: store.return_reasons.first, # or nil
created_by: current_admin # nil = customer self-service (window enforced)
)
result.success? ? result.value : result.error
Creation guards (all three): the order must be completed and not canceled, items non-empty; returns/exchanges also refuse quantities above what is still returnable — units on non-canceled earlier returns
and exchanges count against each other (
Spree::Returns::ReturnableQuantity
), as do units named twice in one request. Claims are bounded the same way per line item: units already covered by
/
/
claims can't be claimed again (denied or canceled claims free them).
Return policy: window, overrides, custom rules
Core ships exactly one policy rule:
Spree::Returns::EligibilityValidator
, registered on
both and
exchanges.create.validate
.
- The window is
market.preferred_return_window_days
(default 30; = no limit), falling back to the default when the order has no market. It is measured from (placement), not delivery.
- It is skipped when is present — staff (Admin API, and the Seller API) opening a return are treated as an override, attributable through . Store API (customer) returns are always checked.
- (default 14, EU floor) on the market is a separate statutory notice value — it doesn't gate return creation.
- The legacy
Spree::Config.return_eligibility_number_of_days
is deprecated and read by nothing.
Replace or extend the rule with your own handler. Core registers its validator
before application initializers run, so the swap works at the top level of
config/initializers/spree.rb
:
ruby
# app/services/my_store/return_policy.rb
module MyStore
class ReturnPolicy
def call(workflow) # Spree::Returns::Create or Spree::Exchanges::Create
return if workflow.created_by.present? # keep the staff override
order = workflow.order
window = order.market&.preferred_return_window_days || 30
delivered_at = order.fulfillments.maximum(:delivered_at) || order.completed_at
workflow.reject!("Returns are accepted for #{window} days after delivery") if delivered_at < window.days.ago
final_sale = workflow.items.any? do |item|
item[:fulfillment_item].variant.product.get_custom_field('policy.final_sale')&.value == 'true'
end
workflow.reject!('Final-sale items cannot be returned') if final_sale
end
end
end
# config/initializers/spree.rb
%w[returns.create.validate exchanges.create.validate].each do |key|
Spree.hooks.unregister(key, 'Spree::Returns::EligibilityValidator')
Spree.hooks.register(key, 'MyStore::ReturnPolicy')
end
Handlers are registered as class-name strings (reload-safe), instantiated with no arguments and called with the workflow.
workflow.reject!(message)
adds a base error and fails the workflow; the API renders it as a 422 like a model validation error. The same pattern gates any later step — e.g.
to require manager approval above an amount, or
to require photos on
. There is no "eligible but needs manual review" state; model that with your own status (
, in
) or metadata.
Receiving and restocking
(and
) takes what the warehouse actually counted. Omit
to receive everything as announced and resellable. Per line:
is recorded, and
only units are restocked —
stock_location.restock(variant, qty, return)
writes a
stock movement caused by the return at the return's stock location. Non-resellable units are recorded but never re-enter stock. Use
to route damaged goods elsewhere or override resellability.
Where goods come back to: the return's
— passed explicitly (
, admin only), otherwise the location that shipped the items if it has
, else the seller's returns location, else
, else whatever shipped them / the store default.
A delivery reporting arrival on a return's inbound parcel never receives the return — arrival is not inspection.
Refunds and settlement
- requires status . defaults to (and is capped by) — only units that actually arrived are owed (
refund_total - refunded_total
). is (default for returns) or (Spree::RefundMethods::METHODS
).
- A returned or claimed unit is worth what the customer paid after discounts, not its list price:
LineItem#discounted_amount_for(units)
(the line's pro-rated, rounded to the currency, never negative) backs ReturnLineItem#default_pre_tax_amount
and ClaimLineItem#paid_amount
. A coupon, promotion or manual discount on the line is not paid back, and a free gift sent back is worth zero.
- A return owed nothing ( zero, e.g. only a free gift came back) is closed by calling with no amount (or ). It moves to with no refund row, store credit, tax credit or refund email; still publishes. Zero on a return that is still owed money fails with , a negative amount with , more than owed with .
- Store credit is an internal ledger write inside the transaction (, originator = the return); gateway refunds run as an after it, split across the order's refundable payments ( rows, originator = the return). Tax is credited through the tax provider afterwards ().
- One refundable balance per order. Every refund path (returns, exchanges, claims — gateway or store credit) goes through
Spree::Refunds::OrderPayments
, which locks the order and caps the amount at what its payments can still refund minus store credit already issued against the order (). Exceeding it fails with , so two post-sale records on one order can't each pay out the full amount.
- counts refunds and store credits. So does the order's : store credit issued by a return, exchange or claim refund carries , and the order moves to / just like a gateway refund.
- Exchanges: builds replacement fulfillments (via
Spree::Stock::Coordinator
) and allocates stock. If the replacements are cheaper ( negative) it credits the difference for the units actually received (not the requested quantity) — by default or . If they cost more, the balance is left for the merchant to collect — core never silently charges a stored card.
- Claims: with one of , , (
Spree::Claim::RESOLUTIONS
); requires . Refund defaults to and the claim total (sum of line s), capped at the sum of the lines' (exposed on claim line items in the API; over it fails ); replacements become new fulfillments on the original order (the customer doesn't place a second order). replacement_line_item_ids
at resolve time overrides what was flagged at creation.
- Returns and claims update the order's through
Spree::Orders::UpdateStatuses
; don't write it.
Prepaid return labels
POST /api/v3/admin/orders/:order_id/returns/:id/labels
with no body buys postage through the carrier account the outbound parcel used (
→ the fulfillment provider's
), booked from the customer back to the return's stock location, and mints an inbound
. With
(signed blob id) +
it records a label bought elsewhere. Customers download it from
GET /api/v3/store/orders/:order_id/returns/:id/label
. Canceling a return refunds an unused prepaid label.
API
Store API (order owner — customer JWT or order token), self-service open + view only:
bash
POST /api/v3/store/orders/:order_id/returns { "items": [{ "fulfillment_item_id": "fi_…", "quantity": 1 }], "reason_id": "rar_…", "memo": "Too small" }
GET /api/v3/store/orders/:order_id/returns[/:id][/label]
POST /api/v3/store/orders/:order_id/claims { "items": [{ "line_item_id": "li_…", "quantity": 1, "description": "Arrived cracked" }], "reason_id": "clr_…" }
GET /api/v3/store/orders/:order_id/claims[/:id]
TypeScript:
client.orders.returns.create(orderId, …)
,
client.orders.claims.create(orderId, …)
.
Admin API — gated by
/
(returns are part of the orders permission resource):
| Endpoint | Notes |
|---|
…/admin/orders/:order_id/returns
(index/show/create/update) | create: items[{fulfillment_item_id, quantity}]
, , , ; update edits , , , only |
PATCH …/returns/:id/approve
· · · | receive: items[{return_line_item_id, quantity, resellable}]
; refund: , ; cancel: |
| (+ , ) | prepaid postage |
…/admin/orders/:order_id/exchanges
+ · · · | create items: , , ; fulfill: |
…/admin/orders/:order_id/claims
+ · · · | create items: , , , , , ; resolve: , , , replacement_line_item_ids
|
GET /api/v3/admin/returns
, , | cross-order lists; Ransack e.g. , |
/api/v3/admin/return_reasons
, | CRUD |
Admin SDK:
adminClient.orders.returns.{create,approve,receive,refund,cancel}
,
,
adminClient.orders.exchanges.{approve,receive,fulfill,cancel}
,
adminClient.orders.claims.{approve,resolve,deny,cancel}
,
adminClient.returns.list(...)
.
Events
,
,
,
,
;
exchange.requested/.approved/.received/.fulfilled/.canceled
;
claim.opened/.approved/.resolved/.denied/.canceled
; plus lifecycle
*.created/.updated/.deleted
for each. All reach webhooks. The optional
gem sends
mail (e.g. the refund email on
). Removed:
,
,
,
.
Gotchas
- Don't transition by — you skip restocking, refunds, events and hooks. Always call the workflow.
- The return window counts from order placement. If your policy counts from delivery (common, and EU withdrawal does), write your own validator (above) — is populated from carrier webhooks or .
- Staff/seller-created returns bypass the window by design. If your policy must apply to staff too, don't early-return on in your handler.
- only runs from ; a return is refunded once. Split tenders are handled inside that one refund (it spreads across payments), not by calling it twice.
- Exchange balances owed by the customer are not charged automatically — collect them yourself (e.g. in an
exchanges.fulfill.after_fulfill
hook or a manual payment).
- The replacement fulfillment, its packing slip and the dashboard's Exchanges card name the replacement variant (), not the original.
- Units an exchange or claim sends are filed under the original line item with
FulfillmentItem#replacement = true
(built through Spree::Fulfillments::Replacements
). Order edits count only bought units, so changing that line's quantity adds or removes paid goods and leaves the replacement alone. Replacement units shipped on earlier 6.0 betas were not flagged and aren't backfilled.
- Claims don't restock anything — nothing comes back. If goods do come back, open a return too.
- Always scope lookups through the order/store (
order.fulfillment_items.find_by_prefix_id!
) — never Spree::FulfillmentItem.find(params[:id])
.
Testing
Factories:
,
,
,
,
,
,
,
,
,
,
, plus
:completed_order_with_totals
/
. Test your policy handler by calling the workflow and asserting
+
messages, not by invoking the handler in isolation only. Set
in non-request specs.
Where to read further
node_modules/@spree/docs/dist/developer/core-concepts/returns-exchanges-claims.md
node_modules/@spree/docs/dist/developer/customization/workflows.md
(hooks, )
node_modules/@spree/docs/dist/developer/core-concepts/store-credits-gift-cards.md
,
- Related skills: spree-workflows, spree-fulfillment (labels, deliveries, replacements), spree-inventory (restock movements), spree-payments (refunds), spree-events-webhooks, spree-upgrade-5-to-6.