Skip to content

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 stamps stripeRefundId + refundedAt; on a Stripe error it stamps refundFailedAt + 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, or STRIPE_SECRET_KEY is 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, and REJECTED via the email outbox. The COMPLETED email is gated on refund success — it only sends when refundedAt is 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 (the return.refund_skipped / return.refund_failed audit 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).