Returns & refunds

When a customer sends items back, Distribu tracks the whole process — from the initial request through inspection, restocking, and refund — as a single return record attached to the original order. Each return is a first-class row in its own right: it has a status, a lifecycle, an audit trail, and webhooks.

You'll find every return at /dashboard/returns. Click any row to open the full return detail page.

The five statuses

A return moves through a fixed workflow:

StatusWhat it meansWho can advance
REQUESTEDStaff has logged the return but no one has decided whether to accept it yet.OWNER, ADMIN, MEMBER
APPROVEDYou've accepted the return. The customer can ship the items back.OWNER, ADMIN, MEMBER
RECEIVEDThe items are physically back in your hands, inspected, and their conditions recorded.OWNER, ADMIN, MEMBER
REFUNDEDA refund has been issued (credit note or manual). Terminal — end of the happy path.OWNER, ADMIN only
REJECTEDYou've declined the return. Terminal.OWNER, ADMIN, MEMBER

Allowed transitions

      ┌────────────┐
      │ REQUESTED  │
      └─────┬──────┘
        ┌───▼───────┐
        │           │
        ▼           ▼
  ┌──────────┐  ┌──────────┐
  │ APPROVED │  │ REJECTED │
  └─────┬────┘  └──────────┘
    ┌───▼────────┐
    │            │
    ▼            ▼
┌──────────┐  ┌──────────┐
│ RECEIVED │  │ REJECTED │
└─────┬────┘  └──────────┘
      ▼
┌──────────┐
│ REFUNDED │
└──────────┘

Written out:

FromCan move to
REQUESTEDAPPROVED, REJECTED
APPROVEDRECEIVED, REJECTED
RECEIVEDREFUNDED
REFUNDED(terminal)
REJECTED(terminal)

Any other transition is blocked server-side. There's no "reopen" — once a return is REFUNDED or REJECTED, it's done. If something needs correcting after the fact, open a new return.

Creating a return

Returns can be opened from either side:

From the dashboard (staff)

From the order detail page (/dashboard/orders/{id}), click Start return. The form asks for:

  • Reason — 3–2000 chars. Free-form text; shown on the return detail page and included in notifications.
  • Items and quantities — pick which line items are coming back and how many of each. You can't return more units than were on the order, and the cumulative return quantity across all open or completed returns against the order can't exceed what was shipped. (If a line item had 10 units and 7 already came back on an earlier return, you can return up to 3 more on this one.)

Staff with OWNER, ADMIN, or MEMBER can create returns. The dashboard path ignores eligibility rules — staff can open a return against any order, even one outside the storefront return window.

From the storefront (customer)

Customers with ADMIN or BUYER roles on their customer account can open a return themselves, from /store/{slug}/orders/{id} via a Request return button. The same reason + items form applies. Customers with the VIEWER role can see orders but can't open returns.

Storefront returns are gated by eligibility:

  1. The order must be in DELIVERED status.
  2. The order must have a deliveredAt timestamp (older orders from before Phase E won't qualify — staff can still open returns manually for those).
  3. The company must have a returnWindowDays configured. null here disables storefront returns entirely — the button is hidden.
  4. now <= deliveredAt + returnWindowDays — within the configured window.

If any of the above fails, the Request return button is hidden from the storefront order page. Customers can still email you for help, and you can open the return from the dashboard.

Cumulative-quantity cap across open returns. In-flight returns (REQUESTED, APPROVED, RECEIVED) count against the per-line quota alongside already-completed returns. This means two storefront tabs or sessions can't both request the last unit of the same line item. Rejecting a return frees its units back up.

Approving, receiving, and rejecting

From the return detail page at /dashboard/returns/{id}, action buttons appear based on the current status:

Approve (REQUESTED → APPROVED)

One click. No extra fields. The customer is now expected to ship the items back.

Reject (REQUESTED → REJECTED or APPROVED → REJECTED)

Opens an inline form with an optional reason field. Rejection is terminal — the return is closed and no refund is issued.

Mark received (APPROVED → RECEIVED)

Opens an inline form where you record the condition of each returned item as it arrives:

  • GOOD — the unit is sellable. Distribu increments Product.stock by the returned quantity and writes a StockMovement row with reason = RETURN_RECEIVED. See Stock tracking.
  • DAMAGED — the unit isn't sellable. No stock change. The return item is still recorded so the customer still gets their refund.

You can mix conditions on a single return — 2 GOOD + 1 DAMAGED of the same product is fine. Each line is logged individually.

Issue refund (RECEIVED → REFUNDED)

OWNER or ADMIN only. Opens a refund form:

FieldTypeNotes
methodenumCREDIT (issue a credit note) or MANUAL (you'll refund outside Distribu — cash, bank transfer, card reversal).
notestringOptional, 0–2000 chars. Internal context shown on the refund row.
amountOverridedecimalOptional. Leave blank to use the computed total (sum of returned quantity × unitPrice). Override to refund a different amount (partial refund, restocking fee, etc.) — capped at the order total so you can never refund more than the customer paid.

On submit:

  • A Refund row is created with the chosen method + amount.
  • If method is CREDIT, a CreditNote is also created, attached to the customer. The customer can apply it against a future order.
  • The return transitions to REFUNDED — terminal.

Anatomy of a return

FieldTypeNotes
idstringPrimary key. Shown in URLs.
statusenumREQUESTED, APPROVED, RECEIVED, REFUNDED, REJECTED.
reasonstringRequired at creation.
orderIdreferenceThe source order.
customerIdreference, optionalCopied from the order. Null if the order was dashboard-created and had no customer.
requestedByUserIdreferenceThe staff member who created the return.
approvedByUserId / receivedByUserId / rejectedByUserIdreferenceSet at each transition. Useful in the audit trail.
requestedAt / approvedAt / receivedAt / refundedAt / rejectedAttimestampSet at each transition. Null until reached.
itemsrelationOne row per returned line item.

Return items

Every return item has:

  • orderItemId — reference to the original line item. Unique per return (you can't return the same line twice in one return, but you can open multiple returns against the same line over time).
  • quantity — positive integer.
  • conditionReceivedGOOD, DAMAGED, or null (set at the RECEIVED transition).

Refunds

Refunds are separate from returns on purpose — you can issue a refund without a return (e.g., a service credit) and a single return can, in theory, be tied to multiple refunds. In practice, one return → one refund → one optional credit note is by far the common path.

Each refund has:

FieldTypeNotes
methodenumCREDIT or MANUAL.
amountdecimalWhat was refunded.
notestring, optionalInternal context.
orderIdreferenceAlways set.
returnIdreference, optionalSet when the refund is tied to a return.

Credit notes

When a refund uses method CREDIT, Distribu creates a credit note — a balance the customer can apply to a future order. Credit notes have:

FieldTypeNotes
amountdecimalRefunded amount.
reasonstringCarried over from the refund context.
customerIdreferenceWho owns it.
refundIdreferenceThe refund that created it.
consumedOnOrderIdreference, optionalSet when the credit is applied to a future order.

Manual refunds do not create credit notes — you're refunding out of band and tracking it in your own system.

Side effects

Every return transition writes to three systems.

1. Audit log

TransitionAudit action
Return createdReturnRequested
Return approvedReturnApproved
Return receivedReturnReceived
Return rejectedReturnRejected
Refund issuedRefundProcessed
Credit note createdCreditNoteIssued

Every entry includes the actor (staff user), the return / refund ID, and key metadata. Visible in Settings → Audit log.

2. Webhooks

Three events fire from returns / refunds:

EventFires when…
return.createdA return is logged (REQUESTED).
return.approvedA return transitions to APPROVED.
refund.processedA refund is issued (at the REFUNDED transition).

See Webhooks → Event types for payload shapes.

3. In-app notifications

EventNotification typeWho sees it
Return requestedRETURN_REQUESTEDOWNER, ADMIN, MEMBER
Refund processedREFUND_PROCESSEDOWNER, ADMIN

See Notifications for the bell-icon UX and fan-out rules.

What's not here (yet)

  • REST API endpoints for returns / refunds. Not exposed at /api/v1/** yet — dashboard and storefront only.
  • Return shipping labels. Distribu doesn't generate or track return labels — you send them out of band.
  • Automatic partial restocks on cancel. Cancelling an order doesn't create a return or touch stock; see Order statuses → Stock and status.
  • Per-customer return window overrides. The return window (returnWindowDays) is set company-wide, not per customer.

Related: