Skip to content

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. Use PATCH /merchant/returns/:id/intake before receipt, or send the same item outcomes with the RECEIVED status transition to apply both atomically. The status transition requires a non-empty reason, captured with the condition and restock audit evidence.
  • Refund amount — PATCH /merchant/returns/:id/refund-amount records the intended cash amount, bounded by the refundable cap. Send allocations with non-empty per-Return-item amounts that sum exactly to every positive refund; send [] when the amount is 0. Setting 0 records that this Return will not issue a cash refund; the endpoint itself never moves money.
  • Requesting COMPLETED — for a positive cash refund, HTTP 200 acknowledges the command, not necessarily terminal completion. The canonical Return can remain RECEIVED while its refund generation is processing. Always consume the returned status and merchant-safe refundWorkflow instead 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 — inspect problemCode. A retry is available only when retryAvailable is 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:

  1. The signed provider event is persisted and revalidated against the exact tenant, connected account, Refund, PaymentIntent, amount, currency, Return, and refund attempt.
  2. An append-only REFUND_REVERSAL ledger transaction restores effective payment truth while preserving the historical refund.
  3. 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.
  4. A correction notification obligation is created, and any affected tax reversal is suppressed, compensated, or held for reconciliation through its own durable state machine.
  5. 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 and amountOwedInCents is 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.