Returns / RMA
A customer requests a return against an order; a merchant moves it through an
approval and intake state machine. Receipt and refund processing coordinate
restock, money, and customer-notification work through separate durable
boundaries. 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.
If the order exists but the supplied email does not match, the public return
flow deliberately returns 403 instead of a neutral 404. This is the narrow
issue #1475 UX/security exception: it helps legitimate customers correct which
email they used at checkout, while tenant scoping, public rate limiting, and a
non-detail error body limit probing value.
// 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.
Receipt, refund, and completion
- On
RECEIVED— item intake outcomes are applied and resaleable units are restocked inside the transition transaction. UsePATCH /merchant/returns/:id/intakebefore receipt, or send the same item outcomes with theRECEIVEDstatus transition to apply both atomically. The status transition requires a non-emptyreason, captured with the condition and restock audit evidence. - Refund amount —
PATCH /merchant/returns/:id/refund-amountrecords the intended cash amount, bounded by the refundable cap. Sendallocationswith non-empty per-Return-item amounts that sum exactly to every positive refund; send[]when the amount is0. Setting0records that this Return will not issue a cash refund; the endpoint itself never moves money. - Requesting
COMPLETED— for a positive cash refund, HTTP200acknowledges the command, not necessarily terminal completion. The canonical Return can remainRECEIVEDwhile its refund generation is processing. Always consume the returnedstatusand merchant-saferefundWorkflowinstead of inferring success from the HTTP response.
The merchant workflow reports one of these high-level states:
READY/PROCESSING— eligible or actively processing; do not start a parallel refund.NEEDS_ATTENTION— inspectproblemCode. A retry is available only whenretryAvailableis true, which means the prior generation has durable provider no-money proof and its processing event is acknowledged. Ambiguous or unavailable provider truth remains fail-closed.SUCCEEDED— the exact refund and Return completion evidence agree.REFUND_REVERSED_NEEDS_REIMBURSEMENT— the provider later reversed a refund that had already succeeded; the customer is still owed the exposed amount.REFUND_REVERSAL_RECONCILIATION_REQUIRED— later provider observations conflict or remain unsafe to act on. No new refund or remediation closure is implied.RESOLUTION_PENDING/RESOLVED— the reimbursement amount is covered, then the independent notification/tax obligations are pending or complete.
The initial completion notification is admitted only after exact refund
success. A deterministic provider rejection before success keeps the Return at
RECEIVED; it does not send success copy.
When a successful refund is later reversed
A signature-verified Stripe refund.failed (or equivalent terminal evidence)
can arrive after the exact Refund was already recorded as succeeded. In that
case litecommerce does not delete or rewind the original success:
- The signed provider event is persisted and revalidated against the exact tenant, connected account, Refund, PaymentIntent, amount, currency, Return, and refund attempt.
- An append-only
REFUND_REVERSALledger transaction restores effective payment truth while preserving the historical refund. - The Return remains
COMPLETED, but a tenant-scoped refund exception becomes operator-visible. Public/customer reads change to correction truth rather than continuing to claim the refund arrived. - A correction notification obligation is created, and any affected tax reversal is suppressed, compensated, or held for reconciliation through its own durable state machine.
- Ordinary and grouped card-refund paths remain fenced while the exception or a later provider-reconciliation hold is active, preventing an automatic second refund.
This exception does not change shipping, fulfillment, Return intake, or inventory state. It corrects money and customer-notification truth after the goods workflow has already completed.
Customer-facing refund state
Every public Return includes required refundState and nullable
amountOwedInCents fields. Relevant values are:
NOT_APPLICABLE— the Return was rejected or cancelled, or no cash refund applies.PROCESSING/ISSUED— the ordinary refund path is pending or complete.REIMBURSEMENT_PENDING— a post-success reversal exists andamountOwedInCentsis still positive.RECONCILIATION_REQUIRED— provider truth is contradictory or incomplete; no payment outcome should be inferred.REIMBURSEMENT_RECORDED— alternative reimbursement covers the amount, but all independent resolution obligations are not yet terminal.RESOLVED— the exception has closed.
amountOwedInCents is null when no post-success correction exists and never
goes below zero.
Claim and record remediation
Owner/Admin retain broad access throughout this workflow; Staff needs
returns:operate to claim the exception:
await api(`/merchant/returns/${id}/refund-exception/claim`, {
method: "POST",
token,
});Claiming assigns ownership only. It changes no ledger, Return, notification, provider, tax, shipping, fulfillment, or inventory state.
After the merchant has already reimbursed the customer through a non-card
method, record that observed money movement with the high-risk remediation
command. Staff recording requires both returns:operate and
shared-commerce:manual-payments. The command is also gated by the
shared_commerce.manual_payment_record high-risk action:
await api(`/merchant/returns/${id}/refund-exception/reimbursements`, {
method: "POST",
token,
headers: { "x-idempotency-key": operationKey },
body: JSON.stringify({
amountInCents: 4200,
method: "BANK_TRANSFER", // CASH | CHECK | BANK_TRANSFER | STORE_CREDIT | OTHER
currency: "usd",
occurredAt: new Date().toISOString(),
reference: "merchant receipt 4815",
reason: "Customer confirmed the replacement reimbursement.",
}),
});This command records completed alternative reimbursement; it cannot send money and performs no provider call. It appends an ordinary Sales refund, reserves the corresponding tax-reversal intent, and is idempotent for the same operation key and facts. Partial reimbursement leaves the remaining balance visible. Exact full coverage can advance notification resolution only after the independent ledger, tax, and delivery evidence permits it.
Correction and resolution notifications
The merchant detail exposes the exception's notification rows as
CORRECTION, RESOLUTION, or a superseding COMBINED notice, each with an
independent status. PROVIDER_ACCEPTED means the provider accepted the request,
not that the customer's inbox received it. BLOCKED, READY, and
PROVIDER_IN_FLIGHT likewise are not proof the customer received the message.
DEFINITIVELY_NOT_ACCEPTED proves the provider did not accept that notice,
while SUPERSEDED means a later combined notice owns its obligation; neither
is delivery proof. A resolution notice cannot overtake an unconsumed or
ambiguous correction obligation, and exact retries do not create a second
notice.
The ordinary APPROVED, RECEIVED, and REJECTED lifecycle notifications
continue through the outbox. Logged-in customer session auth is separate from
today's public email-match flow.
Related
- Orders & fulfillment — the order a return is filed against
- Inventory — where restocked units land
- API reference — exact Return workflow, exception, and remediation schemas