Returns / RMA
A customer requests a return against an order; a merchant moves it through an
approval state machine. Reaching the terminal states triggers real side
effects — restock, refund, and customer emails. Examples reuse the
api() helper.
Customer side (public)
A customer is authenticated by matching the email the order was placed under — no login (session auth arrives with litecheckout, M3). The order id
- matching email is the credential.
// Request a return
await api(`/public/orders/${orderId}/returns`, {
method: "POST",
body: JSON.stringify({
customerEmail: "dana@example.com", // must match the order
items: [{ orderItemId: "…", quantity: 1 }], // 1–50 lines; qty ≤ unreturned qty
reason: "Wrong size", // optional, ≤4000
}),
});
// List this order's returns
await api(`/public/orders/${orderId}/returns?email=dana@example.com`);
// Cancel (allowed while REQUESTED or APPROVED)
await api(`/public/orders/${orderId}/returns/${returnId}/cancel`, {
method: "POST", body: JSON.stringify({ customerEmail: "dana@example.com" }),
});A request can't claim more than was ordered minus already-returned quantity.
Merchant side
Reads (GET /merchant/returns, /:id) are open to any member. Status + notes
mutations require returns:operate; Owner/Admin retain broad access, and Staff
needs that group.
await api(`/merchant/returns/${id}/status`, { method: "PATCH", token,
body: JSON.stringify({ status: "APPROVED" }) });
await api(`/merchant/returns/${id}/notes`, { method: "PATCH", token,
body: JSON.stringify({ notes: "Inspected — resaleable" }) });State machine
ReturnStatus:
REQUESTED → APPROVED → IN_TRANSIT → RECEIVED → COMPLETED
└──────────┴───────────┴──── CANCELLED
REQUESTED → REJECTED
COMPLETED, REJECTED, and CANCELLED are terminal.
What the terminal transitions do
- On
RECEIVED— resaleable lines are restocked automatically inside the transition transaction (a line whose inventory was out-of-stock fires a back-in-stock notification). - On
COMPLETED— a Stripe refund is attempted, outside the state-machine transaction so a Stripe outage can't undo the merchant's completion. On success it stampsstripeRefundId+refundedAt; on a Stripe error it stampsrefundFailedAt+refundError. The attempt is skipped (all four columns left null, a warning logged) when the refund amount is ≤ 0, the order has no Stripe payment intent, orSTRIPE_SECRET_KEYis unset — e.g. a completion where a restocking fee covers the order or credit is issued out of band. - Customer notifications fire on
APPROVED,RECEIVED, andREJECTEDvia the email outbox. TheCOMPLETEDemail is gated on refund success — it only sends whenrefundedAtis set, because the template's copy is hardcoded to "refund issued." On a skipped or failed refund it's suppressed and the merchant follows up out of band (thereturn.refund_skipped/return.refund_failedaudit rows record why).
Restock, refund issuance, and the lifecycle emails are all live today. The only deferred piece is logged-in customer session auth (today's email-match is the interim credential, M3 replaces it).
Related
- Orders & fulfillment — the order a return is filed against
- Inventory — where restocked units land