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:
| Status | What it means | Who can advance |
|---|---|---|
REQUESTED | Staff has logged the return but no one has decided whether to accept it yet. | OWNER, ADMIN, MEMBER |
APPROVED | You've accepted the return. The customer can ship the items back. | OWNER, ADMIN, MEMBER |
RECEIVED | The items are physically back in your hands, inspected, and their conditions recorded. | OWNER, ADMIN, MEMBER |
REFUNDED | A refund has been issued (credit note or manual). Terminal — end of the happy path. | OWNER, ADMIN only |
REJECTED | You've declined the return. Terminal. | OWNER, ADMIN, MEMBER |
Allowed transitions
┌────────────┐
│ REQUESTED │
└─────┬──────┘
┌───▼───────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ APPROVED │ │ REJECTED │
└─────┬────┘ └──────────┘
┌───▼────────┐
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│ RECEIVED │ │ REJECTED │
└─────┬────┘ └──────────┘
▼
┌──────────┐
│ REFUNDED │
└──────────┘
Written out:
| From | Can move to |
|---|---|
REQUESTED | APPROVED, REJECTED |
APPROVED | RECEIVED, REJECTED |
RECEIVED | REFUNDED |
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:
- The order must be in
DELIVEREDstatus. - The order must have a
deliveredAttimestamp (older orders from before Phase E won't qualify — staff can still open returns manually for those). - The company must have a
returnWindowDaysconfigured.nullhere disables storefront returns entirely — the button is hidden. 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 incrementsProduct.stockby the returned quantity and writes aStockMovementrow withreason = 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:
| Field | Type | Notes |
|---|---|---|
method | enum | CREDIT (issue a credit note) or MANUAL (you'll refund outside Distribu — cash, bank transfer, card reversal). |
note | string | Optional, 0–2000 chars. Internal context shown on the refund row. |
amountOverride | decimal | Optional. 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
Refundrow is created with the chosen method + amount. - If method is
CREDIT, aCreditNoteis 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
| Field | Type | Notes |
|---|---|---|
id | string | Primary key. Shown in URLs. |
status | enum | REQUESTED, APPROVED, RECEIVED, REFUNDED, REJECTED. |
reason | string | Required at creation. |
orderId | reference | The source order. |
customerId | reference, optional | Copied from the order. Null if the order was dashboard-created and had no customer. |
requestedByUserId | reference | The staff member who created the return. |
approvedByUserId / receivedByUserId / rejectedByUserId | reference | Set at each transition. Useful in the audit trail. |
requestedAt / approvedAt / receivedAt / refundedAt / rejectedAt | timestamp | Set at each transition. Null until reached. |
items | relation | One 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.conditionReceived—GOOD,DAMAGED, ornull(set at theRECEIVEDtransition).
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:
| Field | Type | Notes |
|---|---|---|
method | enum | CREDIT or MANUAL. |
amount | decimal | What was refunded. |
note | string, optional | Internal context. |
orderId | reference | Always set. |
returnId | reference, optional | Set 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:
| Field | Type | Notes |
|---|---|---|
amount | decimal | Refunded amount. |
reason | string | Carried over from the refund context. |
customerId | reference | Who owns it. |
refundId | reference | The refund that created it. |
consumedOnOrderId | reference, optional | Set 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
| Transition | Audit action |
|---|---|
| Return created | ReturnRequested |
| Return approved | ReturnApproved |
| Return received | ReturnReceived |
| Return rejected | ReturnRejected |
| Refund issued | RefundProcessed |
| Credit note created | CreditNoteIssued |
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:
| Event | Fires when… |
|---|---|
return.created | A return is logged (REQUESTED). |
return.approved | A return transitions to APPROVED. |
refund.processed | A refund is issued (at the REFUNDED transition). |
See Webhooks → Event types for payload shapes.
3. In-app notifications
| Event | Notification type | Who sees it |
|---|---|---|
| Return requested | RETURN_REQUESTED | OWNER, ADMIN, MEMBER |
| Refund processed | REFUND_PROCESSED | OWNER, 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:
- Stock tracking — how
RETURN_RECEIVEDstock movements appear. - Webhooks → Event types —
return.created,return.approved,refund.processedpayloads. - Notifications — the bell-icon stream.
