# Layer 10 — Returns & Proactive Refunds (Design)

**Status:** Draft · 2026-05-07
**Predecessors:** Layer 4 (Cart/Checkout), Layer 5 (Fulfillment/Shipping), Layer 6 (Notifications), Layer 7 (Seller Dashboard), Layer 8 (Admin Console & Disputes), Layer 9 (Purchase Messaging)
**Successors:** TBD (likely: payout adjustments / negative-payout collection, reviews & ratings)

---

## Purpose

Close the trust loop on the buyer side. Today refunds are admin-only — buyers who get a damaged or wrong item have to email support, and sellers who realize they shipped a mistake have to do the same. This layer adds:

1. **Buyer-initiated returns** — a structured request → seller decision → ship-back → refund flow.
2. **Seller-initiated proactive refunds** — a "Refund this order" action on the seller order detail, optionally with no physical return ("keep it, here's your money back").
3. **Escalation to admin** — when the seller rejects a buyer's return, the buyer can push it to admin for a binding decision (separate from a Stripe chargeback).

This is the first layer where a buyer can move money on their own (refund request) — every previous money-flow event has been initiated by the seller (capture on ship) or admin (force-cancel, refund). Money-flow surface area is large; the spec is conservative about what's in scope.

## User model

- **Buyer.** Reactive — opens a return when something is wrong with a delivered order. Low frequency per buyer (a handful per year). Wants the request to be quick to file, transparent in status, and forgiving when they don't have tracking numbers handy.
- **Seller.** Two modes: **reactive** (responds to a buyer return request, decides approve/reject/restock-fee), and **proactive** (notices their own mistake and refunds the buyer voluntarily before the buyer escalates). Cadence depends on store volume; a 50-orders/week store might see 1-3 returns/week.
- **Admin.** Only involved when escalated — reads the seller's rejection reason + the buyer's argument, then decides. Same cadence as disputes today (a few per week).

Implications:

- The state machine drives both the API and the UI. Both surfaces render the same set of states; transitions are explicit user actions, not background jobs (except EasyPost tracking webhooks updating `in_transit` → `received`).
- Buyers and sellers see the *same* return record from different angles — symmetric data, asymmetric available actions.
- Returns share a thread with the order's existing Layer 9 conversation. State changes appear as system rows in that thread so context isn't fragmented.

## Scope

### In scope

- **Buyer-initiated return request** — reason picker (enum), free-text detail, attached photo evidence (reuses Layer 9 attachment infra), per-line-item selection (return all or some).
- **Seller decision** — approve (with optional restocking-fee % up to the per-store cap), or reject (with required justification).
- **Seller-initiated proactive refund** — "Refund this order" action with options: full or partial amount, with/without physical return ("keep it" path).
- **Return shipping** — EasyPost return label generated server-side when the return is approved AND requires shipment. Payer determined by reason (see below); seller-paid labels recorded with cost (not auto-collected in v1).
- **Reason enum** drives default payer:
  - `damaged`, `wrong_item`, `not_as_described` → seller pays return shipping
  - `doesnt_fit`, `changed_mind`, `other` → buyer pays
  - Seller may override per-request (goodwill on a "doesn't fit", etc.)
- **Tracking** — EasyPost webhooks update the return's state from `awaiting_shipment` → `in_transit` → `received`. Seller can also manually mark received in case the carrier event is missed.
- **Refund** — Stripe partial-or-full refund issued automatically when the seller marks the return received (or immediately when seller-initiated `keep-it`). Refund amount = item price (× qty) − restocking fee. Original outbound shipping is not refunded by default (configurable per-return by seller).
- **14-day window** — buyer cannot open a return more than 14 days after `delivered_at`. Calculated server-side; OpenAPI surfaces a `returns_open_until` ISO timestamp on the order resource so the UI can hide the "Request return" button cleanly.
- **One open return per order** — at most one return in non-terminal state at a time. After a closed/cancelled return, a new one can be opened if the window is still open.
- **Restocking fees** — per-store config `restocking_fee_percent` (0–50), set in store settings. Only applies on buyer-initiated returns where reason ∈ {`doesnt_fit`, `changed_mind`, `other`}; seller picks 0 to the store cap when approving. Server enforces.
- **Escalation** — separate `ReturnEscalation` model. Only the buyer can escalate, only after a `rejected` decision, only within 7 days of rejection. Admin queue at `/admin/returns` (escalations only). Admin decision: `refund_buyer` (overrides seller), `side_with_seller` (closes the return), `require_return_then_refund` (forces seller to issue a return label and proceed normally).
- **Messaging integration** — every state change writes a system row to the existing Layer 9 thread (`MessageRole::System`, italic gray, no avatar). Buyer/seller can also exchange normal messages in the same thread about the return — no separate conversation surface.
- **Notifications** — new `NotificationCategory::Returns` case. Notifications fire on every state transition the recipient cares about (e.g., seller gets `ReturnRequestedNotification`, buyer gets `ReturnApprovedNotification` etc.). Database + email channels.
- **Audit** — every admin escalation resolution writes a `spatie/activitylog` row (`return.escalation_resolved`).

### Out of scope

- **Multi-leg flows** — return → exchange (returning one item and getting a different one). v1 is refund-only.
- **Bulk returns across multiple orders** — file one at a time.
- **Auto-approve based on seller config** — seller approves each one explicitly.
- **Pickup scheduling** — buyer drops off / hands to carrier; no door-pickup orchestration.
- **International returns / customs forms** — domestic US only (matches current order shipping scope).
- **Returns for digital products** — we don't ship any.
- **Negative-payout collection** — we record the seller-paid label cost on the return but don't auto-deduct from the next payout. That mechanic is a follow-up; until then it's a manual reconciliation item for the seller.
- **Insurance on return shipments** — no.
- **Buyer-cancel-after-approval** — once a return is approved and a label is issued, the buyer can't cancel client-side (would leave the seller holding the cost). They'd have to message the seller and the seller can voluntarily mark `closed` without refund.
- **Disputes via Stripe** — separate path, already exists. Returns are pre-dispute; Stripe disputes remain available if the buyer refuses to use the returns flow or escalation goes against them.
- **Reviews/ratings** — explicitly deferred to a later layer.

## Information architecture

### Buyer

On `/purchases/[id]`, each order card gets a **"Return this order"** button when:
- `delivered_at IS NOT NULL`
- `now() <= delivered_at + 14d`
- No open return exists

Click → modal with: which items (per-line checkboxes, default all), reason picker, free-text notes, photo upload. Submit → return moves to `requested`.

Once a return exists for an order (any state), the order card shows a **Return status** strip with state badge + last-event timestamp + "View return" link. Click → `/purchases/[id]/returns/[returnId]` which renders the return timeline + the same Layer 9 thread (already on the page) scrolled to the return system row.

### Seller

New `/seller/returns` route — table of all returns across the seller's stores, filterable by state (`requested`, `approved`, `awaiting_shipment`, `in_transit`, `received`, `refunded`, `closed`, `cancelled`, `escalated`), sortable by created/last activity. Defaults to `requested` filter to highlight "needs my attention".

The existing `/seller/orders/[id]` page gets a **Returns panel** (rendered after Messages, before Payout). When no return exists: shows the "Issue refund (proactive)" button. When one exists: full return timeline + transition actions appropriate to current state.

Bell notification on `ReturnRequestedNotification` routes to `/seller/returns` filtered to `requested`.

### Admin

`/admin/returns` — table of *escalated* returns only (admin doesn't need to see every routine return). Same filter shape as seller view but admin-scoped. Click row → `/admin/orders/[id]` with the return panel pre-expanded; admin sees the full timeline + escalation argument + decision actions.

The existing `/admin/orders/[id]` page gets a Returns panel *only when* the order has at least one return — keeps the page clean for the 95% of orders without one.

## Section-by-section design

### Data model

```
returns
  id (uuid, PK)
  order_id (uuid, FK, indexed; NOT unique — historical returns + one open allowed)
  initiated_by (enum: buyer / seller)
  initiator_user_id (uuid, FK)
  state (enum: requested, approved, rejected, awaiting_shipment, in_transit, received, refunded, closed, cancelled, escalated)
  reason (enum: damaged, wrong_item, not_as_described, doesnt_fit, changed_mind, other)
  reason_text (text, nullable; required when reason='other' or on rejection)
  return_shipping_payer (enum: seller, buyer, none)  -- 'none' for keep-it
  restocking_fee_cents (unsignedInteger, default 0)
  refund_amount_cents (unsignedInteger, nullable; set when refunded)
  stripe_refund_id (string, nullable)
  easypost_shipment_id (string, nullable)        -- the return label
  easypost_shipment_cost_cents (unsignedInteger, nullable)
  tracking_number (string, nullable)
  carrier (string, nullable)
  -- transition timestamps (nullable)
  approved_at, rejected_at, label_issued_at, in_transit_at, received_at, refunded_at, closed_at, cancelled_at
  created_at, updated_at
  -- partial unique constraint: at most one return per order in non-terminal state
  -- enforced via a partial index in postgres: WHERE state NOT IN ('closed','cancelled')

return_items
  id (uuid, PK)
  return_id (uuid, FK)
  order_item_id (uuid, FK)
  quantity (unsignedInteger, default 1)
  -- index (return_id)

return_escalations
  id (uuid, PK)
  return_id (uuid, FK, UNIQUE)                   -- one escalation per return ever
  buyer_user_id (uuid, FK)
  buyer_argument (text)
  resolved_at (timestamp, nullable)
  resolved_by_user_id (uuid, FK, nullable)
  resolution (enum: refund_buyer, side_with_seller, require_return_then_refund, nullable)
  resolution_notes (text, nullable)
  created_at, updated_at
```

Per-store config (extend `store_settings`):
- `restocking_fee_percent_max` (smallint, default 20, range 0–50)

Order resource gains:
- `returns_open_until` (ISO string, nullable — `delivered_at + 14d` if eligible, else null)
- `open_return_id` (uuid, nullable — present iff non-terminal return exists)

### State machine

Valid transitions:

```
buyer-initiated:
  (start) --request--> requested
  requested --seller_approve--> approved
  requested --seller_reject--> rejected
  requested --buyer_cancel--> cancelled

  approved --label_issued (auto on approve when shipping required)--> awaiting_shipment
  approved --seller_mark_keep_it (rare; only if approve flipped to no-return)--> refunded   *
  awaiting_shipment --easypost_in_transit_event--> in_transit
  awaiting_shipment --seller_mark_received (manual)--> received
  in_transit --easypost_delivered_event--> received
  in_transit --seller_mark_received (manual override)--> received
  received --refund_issued (auto on transition)--> refunded
  refunded --(auto)--> closed

  rejected --buyer_escalate (within 7d)--> escalated
  rejected --(idle 7d)--> closed
  escalated --admin_resolve_refund_buyer--> refunded
  escalated --admin_resolve_side_with_seller--> closed
  escalated --admin_resolve_require_return--> awaiting_shipment

seller-initiated (proactive):
  (start) --proactive_refund--> approved (with return_shipping_payer = none → 'keep-it') OR awaiting_shipment (with shipping)
  rest follows the same paths from approved
```

* Note: the `approved → refunded (keep-it)` arrow is a quirk for the seller-initiated proactive path. For buyer-initiated, once `approved` is reached, shipping is required by default; seller can skip it but it's an unusual path (would be entered as proactive at request time, not flipped post-approval).

State machine enforced server-side by `ReturnTransitioner` service — every transition method validates current state, runs side effects (issue refund, generate label, post system message, fire notification), and persists in a transaction.

### Endpoints

```
POST   /v1/orders/{order}/returns                    Buyer creates a return request
                                                     body: { reason, reason_text?, item_ids[], attachment_ids[] }
POST   /v1/seller/orders/{order}/returns/proactive   Seller initiates proactive refund
                                                     body: { item_ids[], refund_amount_cents, require_return: bool, reason_text }
GET    /v1/returns/{return}                          Buyer / seller / admin all read same shape
GET    /v1/me/returns                                Buyer's returns list (?state=...)
GET    /v1/seller/returns                            Seller's returns across stores (?state=...)
GET    /v1/admin/returns                             Admin queue (escalations only by default)

POST   /v1/returns/{return}/approve                  Seller approves a buyer's request
                                                     body: { restocking_fee_cents?: int, override_payer?: 'seller'|'buyer' }
POST   /v1/returns/{return}/reject                   Seller rejects (justification required)
                                                     body: { reason_text: string }
POST   /v1/returns/{return}/cancel                   Buyer cancels their own request (only while requested)
POST   /v1/returns/{return}/escalate                 Buyer escalates after rejection
                                                     body: { argument: string }
POST   /v1/returns/{return}/mark-received            Seller manually marks the return received
POST   /v1/admin/returns/{return}/resolve            Admin resolves an escalation
                                                     body: { resolution, resolution_notes }
```

Refund issuance is *not* a manual endpoint — it's a side effect of `mark-received` (or admin `resolve` with `refund_buyer`). Keeps Stripe charge logic in one place.

EasyPost webhook (handled in `Layer 5` shipping webhook controller, extended): on a return-shipment event, look up the return by `easypost_shipment_id` and call `ReturnTransitioner::handleCarrierEvent`.

### Restocking-fee enforcement

When `POST /returns/{id}/approve` is called:
- If `reason ∈ {damaged, wrong_item, not_as_described}` → `restocking_fee_cents` MUST be 0; 422 otherwise.
- Else: `restocking_fee_cents` MUST be ≤ `min(item_subtotal × store.restocking_fee_percent_max%, item_subtotal × 50%)`; 422 otherwise.

Refund calculation (issued on `received` transition):
```
refund = sum(return_item.order_item.price_snapshot × quantity)
       - restocking_fee_cents
       (+ original_shipping_cost iff seller checked "refund original shipping" — defaults off)
```

### Return shipping

When the return enters `awaiting_shipment` (auto on approve when `return_shipping_payer ≠ 'none'`):
1. Server calls `ReturnLabelService::issue(Return, fromAddress: order.shipping_address, toAddress: store.ship_from)` which wraps the existing EasyPost shipment creation with `is_return: true`.
2. Receives `tracking_number`, `carrier`, `easypost_shipment_id`, label PDF URL, `easypost_shipment_cost_cents`.
3. Persists those on the return.
4. Posts a system message in the order's thread with the label download link.
5. Fires `ReturnLabelIssuedNotification` to the buyer.

If the EasyPost call fails: the return stays in `approved` and a `easypost_label_error` field surfaces the upstream message; the seller can retry via a `POST /returns/{id}/retry-label` endpoint (also needed — added to the endpoint list above as an open item; see `Open items`).

### Notifications

New `NotificationCategory::Returns`. Notification classes:

- `ReturnRequestedNotification` → seller (buyer initiated)
- `ReturnApprovedNotification` → buyer
- `ReturnRejectedNotification` → buyer (includes seller's justification)
- `ReturnLabelIssuedNotification` → buyer (includes carrier + tracking number + label URL)
- `ReturnReceivedNotification` → buyer (refund coming) + seller (acknowledgement)
- `ReturnRefundedNotification` → buyer (with amount + Stripe refund id)
- `ReturnEscalatedNotification` → seller + all admins (this is now in admin's queue)
- `ReturnEscalationResolvedNotification` → buyer + seller (with admin's resolution)
- `ProactiveRefundIssuedNotification` → buyer (seller-initiated, no buyer action needed)

All emit on database + email channels, gated by `NotificationCategory::Returns`.

### Audit log

- `return.escalation_resolved` — properties: `return_id`, `order_id`, `resolution`, `notes`, `original_seller_decision_reason`

Routine state transitions are NOT logged in the activity log — the `returns` row's transition timestamps + the order thread's system messages are the audit trail. Only admin moderation hits the log, matching Layer 9's principle.

### Messaging integration

Extend `MessageRole` enum with a new `system` value. `MessagePoster` gains a `postSystem(order, body, metadata)` helper. System messages render in `<MessageRow>` as italic, gray, no avatar, full-width centered (distinct from buyer/seller/admin rows).

`ReturnTransitioner` posts a system message on every state change, e.g.:
- `requested`: "{Buyer} requested a return — reason: damaged."
- `approved`: "{Seller} approved the return. Refund of $X will be issued on receipt. Restocking fee: $Y."
- `awaiting_shipment`: "Return label issued. Tracking: {carrier} {number}."
- `received`: "Return marked received."
- `refunded`: "Refunded $X to original payment method."

System messages are not deletable (the `DELETE /messages/{id}` endpoint refuses when `author_role = 'system'`).

## Cross-cutting

### Auth & guards

- Buyer endpoints: `auth:sanctum`, plus inline check `purchase.buyer_id === user.id`.
- Seller endpoints: `auth:sanctum`, plus `order.store.owner_user_id === user.id`.
- Admin endpoints: `auth:sanctum` + `admin` middleware (matches existing).
- Reuse `MessageThreadAccess` patterns — small `ReturnAccess` action class with `canView(User, Return): bool` and `roleFor(User, Return): ReturnViewerRole`.

### Data fetching (web)

- TanStack Query keys:
  - `['returns', returnId]` for single return
  - `['me', 'returns', { state }]` for buyer list
  - `['seller', 'returns', { state }]` for seller list
  - `['admin', 'returns', { state }]` for admin list
- Mutations invalidate the relevant keys + the order's `['orders', orderId]` (so the order detail page reflects new return state) + the messages key (system messages added).

### API client

`@alqove/api-client` gains a `returns` namespace mirroring the endpoints above, plus `me.returns(params)`, `seller.returns(params)`, `admin.returns(params)`.

### File / module layout

Backend (`api/`):

```
app/Modules/Returns/                              (new module)
  Controllers/
    BuyerReturnController.php                     POST /orders/{order}/returns, GET /me/returns, POST /returns/{id}/cancel, POST /returns/{id}/escalate
    SellerReturnController.php                    POST /seller/orders/{order}/returns/proactive, GET /seller/returns, POST /returns/{id}/approve|reject|mark-received
    ReturnController.php                          GET /returns/{id}
    AdminReturnController.php                     GET /admin/returns, POST /admin/returns/{id}/resolve
  Services/
    ReturnAccess.php                              canView, roleFor
    ReturnTransitioner.php                        all state transitions, side effects in DB transactions
    ReturnLabelService.php                        EasyPost return-shipment wrapper
    ReturnRefundIssuer.php                        Stripe partial-refund wrapper specific to returns
  Resources/
    ReturnResource.php
    ReturnSummaryResource.php                     for list endpoints
    ReturnEscalationResource.php
  Requests/
    CreateReturnRequest.php                       buyer initial
    ProactiveRefundRequest.php                    seller initial
    ApproveReturnRequest.php                      restocking_fee + override_payer
    RejectReturnRequest.php                       reason_text required
    EscalateReturnRequest.php                     argument required
    AdminResolveReturnRequest.php                 resolution + notes
  routes.php

app/Modules/Notifications/Notifications/
  Return*Notification.php                         (one per notification class above)

app/Models/
  Return.php
  ReturnItem.php
  ReturnEscalation.php
  (extend Order: hasMany returns, openReturn() helper)
  (extend StoreSettings: restocking_fee_percent_max)

app/Support/Enums/
  ReturnState.php
  ReturnReason.php
  ReturnInitiatedBy.php
  ReturnShippingPayer.php
  ReturnEscalationResolution.php
  NotificationCategory.php                        + Returns case
  MessageRole.php                                 + System case (Layer 9 extension)

database/migrations/
  ..._create_returns_table.php
  ..._create_return_items_table.php
  ..._create_return_escalations_table.php
  ..._add_restocking_fee_percent_max_to_store_settings.php
```

Web (`web/`):

```
src/components/returns/
  return-timeline.tsx                             state-by-state visual + transition actions per role
  return-request-modal.tsx                        buyer's "request a return" form
  proactive-refund-modal.tsx                      seller's "issue refund" form
  return-state-badge.tsx                          shared badge for all surfaces
src/lib/queries/
  use-returns.ts                                  useReturn, useMyReturns, useSellerReturns, useAdminReturns + mutations
src/app/(buyer)/purchases/[id]/                   (extend purchase-detail-client.tsx)
src/app/(seller)/seller/returns/page.tsx          (new — returns list)
src/app/(seller)/seller/orders/[id]/              (extend order-detail-client.tsx with returns panel)
src/app/(admin)/admin/returns/page.tsx            (new — escalation queue)
src/app/(admin)/admin/orders/[id]/                (extend order-detail-client.tsx with conditional returns panel)
```

## Testing

Backend (Pest feature tests in `tests/Feature/Returns/`):

- Auth gates per role + endpoint
- 14-day window enforcement (boundary at delivered_at + 14d)
- One-open-return-per-order enforcement (partial unique index)
- State machine: every valid transition + every illegal transition returns 422
- Restocking fee enforcement (zero on damaged, capped on doesnt-fit, store-cap respected)
- Refund amount math (qty × price − restocking, optional original-shipping)
- Buyer-initiated full happy path: request → approve → label issued → in_transit (webhook) → received → refunded → closed
- Seller-initiated proactive `keep-it` happy path: refund → closed (no label, no shipping)
- Seller-initiated proactive with-return path
- Reject → escalate (within 7d, blocked after)
- Admin resolutions: each of three outcomes
- System messages posted on every transition (assert on the thread)
- Audit log: only admin escalation resolutions write rows

Web (Vitest + RTL):

- `<ReturnTimeline>` renders per-state UI for buyer / seller / admin viewers
- `<ReturnRequestModal>` validation (per-line items selected, reason picker, attachments)
- `<ProactiveRefundModal>` keep-it vs require-return toggle, partial amount validation
- Buyer surface: button hidden when window closed / open return exists
- Seller surface: filter chips on `/seller/returns`
- Admin surface: queue only shows escalations

Integration: an e2e Playwright happy-path covering buyer requests → seller approves → label issued (mocked EasyPost) → mark-received → refund.

## Privacy / compliance notes

- Buyer photo evidence flows through Spatie MediaLibrary (same disk as item images and message attachments).
- Stripe refund Ids retained indefinitely (matches existing payment id retention).
- EasyPost shipment ids retained indefinitely (matches existing outbound shipment retention).
- Escalation argument text is user-generated; same retention as messages — flag for legal review with the messaging-retention work.

## Open items

These flag for the implementation plans, not blockers for the spec:

- **Negative-payout collection** — we record `easypost_shipment_cost_cents` on the return for seller-paid labels but don't deduct from payouts in v1. Need a follow-up layer to define how that money actually gets reconciled (deduct from next Stripe transfer? Invoice the seller? Out-of-band?).
- **Retry endpoint for failed labels** — `POST /returns/{id}/retry-label` was implied but not enumerated above; confirm during planning whether it needs an explicit admin override path too (e.g., admin issues label themselves if EasyPost is down).
- **Auto-close stale `requested` returns** — should returns sitting in `requested` for 7 days auto-escalate to admin, or just stay open indefinitely? v1 says stays open; revisit if sellers ghost requests.
- **Return shipping address** — the spec assumes the seller's `store.ship_from` address is the return destination. Some sellers might want a separate return address (e.g., a 3PL warehouse). Defer to a per-store config field if it comes up.
- **Email rendering of return state** — the notification emails carry a state badge in the body; rendering is plain HTML in v1 (no rich card with timeline). Polish later.
- **Per-line refund granularity** — the spec allows partial item selection in the request, but the refund math sums the selected items at full snapshot price. If a buyer wants to return 1 of 2 of a multi-quantity line, that's supported via `return_items.quantity`. If they want a *partial* refund on a single item (e.g., "minor scratch, keep it but refund 20%"), that's a seller-initiated proactive flow with `refund_amount_cents` set explicitly, not a buyer-side option.
- **Restocking fee disclosure** — the buyer sees the potential restocking fee only AFTER seller approves. Consider surfacing the store's `restocking_fee_percent_max` on the request modal so the buyer isn't surprised.
- **Multi-leg / exchange flow** — explicitly out of scope, but worth noting that some marketplaces do "return → store credit" or "return → swap" — if that becomes important, the state machine would need a `swap_pending` branch off `received`.
- **Stripe Connect transfer reversal coordination** — when a refund is issued post-payout (transfer already moved funds to the seller's connected account), we need to reverse the transfer too. Layer 8 already does this for admin force-cancels; reuse the `ReverseTransfer` service in `ReturnRefundIssuer` for the same situation.
- **Return shipping label expiration** — EasyPost labels are typically valid for ~30 days; if a buyer doesn't ship within that window, the label is dead. Decide: auto-reissue, or require a manual refresh? v1 doesn't address this.
