# Orange Wine — API Part 05: Orders, Cancellations, Returns and Refunds

Version 1.0 — 28 Sep 2026 · 22 endpoints · Base URL `/api/v1` · Read with Part 00 (conventions, error catalog, permissions).

## 1. Before you start

- Base URL `/api/v1` (webhooks: `/webhooks/...`). JSON, `snake_case` keys, prefixed string IDs, ISO 8601 UTC timestamps.
- Success: `{ data, request_id, correlation_id }`. Error: `{ error: { code, message, details, retryable } }`. Gated feature: `data.enabled = false`, `status = "pending_configuration"`.
- Auth via HttpOnly cookies (`access_token`, `refresh_token`); every non-GET browser request sends `X-XSRF-TOKEN`.
- Money is always `{ amount_minor, currency }`. Percentages are basis points.
- Commands marked Idempotency = Required need an `Idempotency-Key` header (UUID v4).
- Status never changes through PATCH — use the named command endpoints.
- Full conventions, error catalog, permission catalog and open items: Part 00.

### Error codes used in this part

| Code | HTTP | Meaning |
|---|---|---|
| `CANCELLATION_NOT_ELIGIBLE` | 422 | Normal order outside the 24-hour window or already picked. |
| `CANCELLATION_REQUEST_ALREADY_OPEN` | 409 | A cancellation request is already open for this order. |
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |
| `FULFILLMENT_NOT_ELIGIBLE` | 422 | Selected fulfillment mode isn't allowed for this cart/location/time. |
| `INVALID_RETURN_STATE` | 409 | Return is not in a state that allows this action. |
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |
| `PAYMENT_RECONCILIATION_REQUIRED` | 409 | Payment state is ambiguous; staff reconciliation is required. |
| `PRE_ARRIVAL_CANCELLATION_NOT_ELIGIBLE` | 422 | Pre-arrival order doesn't meet an approved cancellation reason. |
| `PROVIDER_UNAVAILABLE` | 503 | External provider unreachable or not configured; safe to retry later. |
| `RATE_LIMITED` | 429 | Too many requests. `Retry-After` header set. Limits are configured later. |
| `REFUND_ALREADY_ISSUED` | 409 | Refund for this scope was already issued. |
| `REFUND_APPROVAL_REQUIRED` | 403 | Refund requires a valid manager approval. |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |
| `RETURN_NOT_ELIGIBLE` | 422 | Item/reason combination isn't returnable. |
| `RETURN_WINDOW_EXPIRED` | 422 | Outside the 30-day return window. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

### Permissions used in this part

| Permission | Grants | Endpoints |
|---|---|---|
| `orders.fulfill` | Dashboard orders, fulfillment steps, cancellation approvals, return review/receive/inspect | 11 |
| `pos.refund` | Issue refunds (always with manager approval) | 2 |

### Endpoints requiring Idempotency-Key

| Method | Path | Title |
|---|---|---|
| POST | `/orders/{orderId}/cancellation-requests` | Request cancellation |
| POST | `/dashboard/orders/{orderId}/cancellation-requests/{requestId}/approve` | Approve cancellation |
| POST | `/dashboard/orders/{orderId}/cancellation-requests/{requestId}/reject` | Reject cancellation |
| POST | `/orders/{orderId}/returns` | Request return |
| POST | `/dashboard/returns/{returnId}/approve` | Approve return |
| POST | `/dashboard/returns/{returnId}/reject` | Reject return |
| POST | `/dashboard/returns/{returnId}/receive` | Mark received |
| POST | `/dashboard/returns/{returnId}/inspect` | Record inspection |
| POST | `/dashboard/orders/{orderId}/refunds` | Issue refund |

### PROPOSED and gated endpoints in this part

| Status | Method | Path |
|---|---|---|
| CONFIRMED rule · PROPOSED shape | POST | `/orders/guest-lookup` |
| PROPOSED | POST | `/orders/{orderId}/curbside-arrival` |
| PROPOSED | GET | `/dashboard/cancellation-requests` |
| PROPOSED | POST | `/orders/{orderId}/return-attachments` |
| PROPOSED | GET | `/dashboard/returns` |
| PROPOSED | GET | `/dashboard/returns/{returnId}` |
| PROPOSED | GET | `/dashboard/orders/{orderId}/refunds` |
| PROPOSED | GET | `/dashboard/orders` |
| PROPOSED | GET | `/dashboard/orders/{orderId}` |

## 2. Orders, Guest Lookup, Cancellations, Returns and Refunds

A customer request never changes order, stock or money by itself (D-24). Refunds are staff-only (D-27). `POST /orders/{orderId}/cancel` does NOT exist.

| # | Method | Path | Title | Status |
|---|---|---|---|---|
| 2.1 | POST | `/orders/guest-lookup` | Guest order lookup | CONFIRMED rule · PROPOSED shape |
| 2.2 | GET | `/orders` | List orders | CONFIRMED |
| 2.3 | GET | `/orders/{orderId}` | Order detail | CONFIRMED |
| 2.4 | POST | `/orders/{orderId}/curbside-arrival` | Customer: I'm here (curbside) | PROPOSED |
| 2.5 | POST | `/orders/{orderId}/cancellation-requests` | Request cancellation | CONFIRMED |
| 2.6 | GET | `/orders/{orderId}/cancellation-requests` | My cancellation requests | CONFIRMED |
| 2.7 | GET | `/dashboard/cancellation-requests` | Cancellation review queue | PROPOSED |
| 2.8 | POST | `/dashboard/orders/{orderId}/cancellation-requests/{requestId}/approve` | Approve cancellation | CONFIRMED |
| 2.9 | POST | `/dashboard/orders/{orderId}/cancellation-requests/{requestId}/reject` | Reject cancellation | CONFIRMED |
| 2.10 | POST | `/orders/{orderId}/return-attachments` | Upload return photo | PROPOSED |
| 2.11 | POST | `/orders/{orderId}/returns` | Request return | CONFIRMED |
| 2.12 | GET | `/orders/{orderId}/returns` | Returns for an order | CONFIRMED |
| 2.13 | GET | `/dashboard/returns` | Return review queue | PROPOSED |
| 2.14 | GET | `/dashboard/returns/{returnId}` | Return detail (staff) | PROPOSED |
| 2.15 | POST | `/dashboard/returns/{returnId}/approve` | Approve return | CONFIRMED |
| 2.16 | POST | `/dashboard/returns/{returnId}/reject` | Reject return | CONFIRMED |
| 2.17 | POST | `/dashboard/returns/{returnId}/receive` | Mark received | CONFIRMED |
| 2.18 | POST | `/dashboard/returns/{returnId}/inspect` | Record inspection | CONFIRMED |
| 2.19 | POST | `/dashboard/orders/{orderId}/refunds` | Issue refund | CONFIRMED |
| 2.20 | GET | `/dashboard/orders/{orderId}/refunds` | Refunds for an order | PROPOSED |
| 2.21 | GET | `/dashboard/orders` | Order list (staff) | PROPOSED |
| 2.22 | GET | `/dashboard/orders/{orderId}` | Order detail (staff) | PROPOSED |

### 2.1 `POST /orders/guest-lookup`

**Guest order lookup**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/orders/guest-lookup` |
| Purpose | Guest proves ownership with order number + email or phone (D-18). Success sets a short-lived, order-scoped guest proof cookie. |
| Auth | Public |
| Status | CONFIRMED rule · PROPOSED shape |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | Yes |

**Request example**

```http
POST /api/v1/orders/guest-lookup HTTP/1.1
Content-Type: application/json
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "order_number": "OW-5001",
  "email": "asha@example.com"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| order_number | string · required | Customer-facing number. |
| email | string · required if no phone | Email used on the order. |
| phone | string · required if no email | E.164 phone used on the order. |

**Response — success (200)**

```http
HTTP/1.1 200 OK
Set-Cookie: guest_order_proof=<opaque>; Secure; HttpOnly; SameSite=Lax; Path=/api/v1/orders

{
  "data": {
    "order_id": "ord_5001",
    "confirmation_number": "OW-5001",
    "status": "paid",
    "fulfillment_status": "unassigned",
    "lines": [
      {
        "name": "Example Cabernet 2023 750ml",
        "quantity": 2
      }
    ],
    "total": {
      "amount_minor": 13592,
      "currency": "USD"
    },
    "tracking": [],
    "cancellation_eligible_until": "2026-09-18T16:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| (all) | GuestOrderProjection | Guest-safe subset; never staff/admin fields. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RESOURCE_NOT_FOUND` | 404 | Any mismatch or unknown order — identical generic message |
| `RATE_LIMITED` | 429 | Too many requests. `Retry-After` header set. Limits are configured later. |
| `VALIDATION_ERROR` | 422 | Neither email nor phone |

**Error example — RESOURCE_NOT_FOUND**

```http
HTTP/1.1 404 Not Found

{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "We couldn't find an order matching those details.",
    "details": {},
    "retryable": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.2 `GET /orders`

**List orders**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/orders` |
| Auth | Customer, or guest proof (returns only the proven order) |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional |  |
| order_type | enum · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/orders?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "ord_5001",
        "confirmation_number": "OW-5001",
        "order_type": "normal",
        "status": "paid",
        "payment_status": "captured",
        "fulfillment_status": "unassigned",
        "total": {
          "amount_minor": 13592,
          "currency": "USD"
        },
        "created_at": "2026-09-17T16:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| pagination.page | integer | Current page. |
| pagination.per_page | integer | Page size used. |
| pagination.total | integer | Total matching items. |
| pagination.last_page | integer | Last page number; 0 when there are no items. |

---

### 2.3 `GET /orders/{orderId}`

**Order detail**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/orders/{orderId}` |
| Auth | Owner (customer or guest proof) |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
GET /api/v1/orders/{orderId} HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "ord_5001",
    "confirmation_number": "OW-5001",
    "channel": "web",
    "order_type": "normal",
    "status": "paid",
    "payment_status": "captured",
    "fulfillment_status": "unassigned",
    "placed_at": "2026-09-17T16:00:00Z",
    "cancellation_eligible_until": "2026-09-18T16:00:00Z",
    "customer": {
      "id": "usr_101",
      "email": "customer@example.com",
      "phone": "+15185550100"
    },
    "lines": [
      {
        "id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "name": "Example Cabernet 2023 750ml",
        "sell_unit": "bottle",
        "quantity": 2,
        "unit_price": {
          "amount_minor": 6275,
          "currency": "USD"
        },
        "discount": {
          "amount_minor": 0,
          "currency": "USD"
        },
        "tax": {
          "amount_minor": 502,
          "currency": "USD"
        },
        "line_total": {
          "amount_minor": 12550,
          "currency": "USD"
        },
        "fulfillment_group_id": "ful_9001",
        "pre_arrival": false
      }
    ],
    "totals": {
      "subtotal": {
        "amount_minor": 12550,
        "currency": "USD"
      },
      "discount": {
        "amount_minor": 0,
        "currency": "USD"
      },
      "tax": {
        "amount_minor": 1004,
        "currency": "USD"
      },
      "shipping": {
        "amount_minor": 600,
        "currency": "USD"
      },
      "total": {
        "amount_minor": 13592,
        "currency": "USD"
      }
    },
    "shipping_address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "ship_free_12_applied": false,
    "rapid_ship": true,
    "fulfillment_groups": [
      {
        "id": "ful_9001",
        "kind": "rapid_ship",
        "mode": "shipping",
        "location_id": "loc_albany",
        "status": "unassigned",
        "shipments": [],
        "rapid_ship_sla": {
          "dispatch_deadline_at": "2026-09-17T23:59:00Z",
          "delivery_target_at": "2026-09-19T23:59:00Z",
          "sla_met": null
        }
      }
    ],
    "pre_arrival": null,
    "open_cancellation_request_id": null,
    "refunds": []
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `draft` \| `pending_payment` \| `payment_failed`* \| `expired`* \| `paid` \| `partially_fulfilled` \| `fulfilled` \| `cancelled` \| `partially_refunded` \| `refunded` (*PROPOSED, D-30). |
| payment_status | enum | `created` \| `pending` \| `authorized` \| `captured` \| `failed` \| `reversed` \| `partially_refunded` \| `refunded` \| `disputed`. |
| channel | enum | `web` \| `pos`. |
| order_type | enum | `normal` \| `pre_arrival`. |
| cancellation_eligible_until | ISO 8601 \| null | Normal orders only; also void once any line is picked. |
| lines[] | OrderLine | Immutable snapshot: sku, name, sell_unit, prices, tax, promotion versions. |
| totals.* | Money object `{ amount_minor: integer, currency: "USD" }` | Snapshot at placement. |
| ship_free_12_applied | boolean | Whether 12 Ship Free waived shipping on this order. |
| rapid_ship | boolean | Order contains a Rapid Ship fulfillment group. |
| fulfillment_groups[].kind | enum | `rapid_ship` \| `standard` \| `pre_arrival`. |
| fulfillment_groups[].status | enum | `unassigned` \| `picking` \| `packed` \| `ready_for_pickup` \| `handed_off` \| `shipped` \| `out_for_delivery` \| `delivered` \| `failed` \| `returned` \| `expired` \| `cancelled`. |
| fulfillment_groups[].rapid_ship_sla | object \| null | `dispatch_deadline_at`, `delivery_target_at`, `sla_met` (null while in progress). |
| pre_arrival | object \| null | Pre-arrival orders: `{ estimated_delivery, revised_arrival_from, revised_arrival_to, commitment_status: committed\|allocated\|cancelled, terms_version }`. |
| open_cancellation_request_id | string \| null |  |
| refunds[] | object[] | `{ refund_id, status, final_refund: Money, created_at }`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Not the owner |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---

### 2.4 `POST /orders/{orderId}/curbside-arrival`

**Customer: I'm here (curbside)**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/orders/{orderId}/curbside-arrival` |
| Auth | Owner |
| Status | PROPOSED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
POST /api/v1/orders/{orderId}/curbside-arrival HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "vehicle": {
    "description": "Blue Honda Civic",
    "plate": "ABC1234"
  },
  "parking_spot": "3"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| vehicle.description | string · optional |  |
| vehicle.plate | string · optional |  |
| parking_spot | string · optional |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "order_id": "ord_5001",
    "fulfillment_id": "ful_9002",
    "arrival_recorded_at": "2026-09-17T19:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FULFILLMENT_NOT_ELIGIBLE` | 422 | Not a curbside order or not ready |

---

### 2.5 `POST /orders/{orderId}/cancellation-requests`

**Request cancellation**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/orders/{orderId}/cancellation-requests` |
| Auth | Owner |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
POST /api/v1/orders/{orderId}/cancellation-requests HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "reason_code": "no_longer_needed",
  "customer_notes": "Found it cheaper locally."
}
```

Do not send request_type; the server derives it.

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| reason_code | enum · required | Must match the order type — see list below. |
| customer_notes | string · optional · max 1000 |  |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "cancellation_request_id": "cxl_3001",
    "order_id": "ord_5001",
    "request_type": "NORMAL",
    "status": "REQUESTED",
    "reason_code": "no_longer_needed",
    "customer_notes": "Found it cheaper locally.",
    "order_status": "paid",
    "reviewed_by": null,
    "reviewed_at": null,
    "staff_reason_code": null,
    "staff_notes": null,
    "refund": null,
    "created_at": "2026-09-17T18:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

The order is NOT cancelled; status stays `paid`.

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| request_type | enum | `NORMAL` \| `PRE_ARRIVAL` — derived by the server from the order type. |
| status | enum | `REQUESTED` \| `APPROVED` \| `REJECTED`. |
| reason_code | enum | NORMAL: `mistake` \| `wrong_product` \| `wrong_quantity` \| `no_longer_needed` \| `delivery_too_long` \| `other`. PRE_ARRIVAL: `no_longer_wanted` \| `delivery_too_long` \| `other`. |
| order_status | enum | Order status — unchanged while REQUESTED. |
| staff_reason_code | enum \| null | See approve/reject. |
| refund | object \| null | Present after approval: `{ refund_id, status, gross_refund, fee_lines[], final_refund }`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `CANCELLATION_NOT_ELIGIBLE` | 422 | Normal order: >24h, a line picked, or shipped |
| `PRE_ARRIVAL_CANCELLATION_NOT_ELIGIBLE` | 422 | Pre-arrival order already fulfilled |
| `CANCELLATION_REQUEST_ALREADY_OPEN` | 409 | An open request exists (PROPOSED code) |
| `VALIDATION_ERROR` | 422 | Reason code not valid for this order type |
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |

**Error example — CANCELLATION_NOT_ELIGIBLE**

```http
HTTP/1.1 422 Unprocessable Entity

{
  "error": {
    "code": "CANCELLATION_NOT_ELIGIBLE",
    "message": "This order can no longer be cancelled.",
    "details": {
      "reason": "window_expired",
      "window_hours": 24,
      "suggested_action": "contact_store"
    },
    "retryable": false
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Notes**

- `details.reason`: `window_expired` | `already_picked` | `already_shipped`. `details.suggested_action`: `return` (once delivered) | `contact_store`.

---

### 2.6 `GET /orders/{orderId}/cancellation-requests`

**My cancellation requests**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/orders/{orderId}/cancellation-requests` |
| Auth | Owner |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
GET /api/v1/orders/{orderId}/cancellation-requests HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "cancellation_request_id": "cxl_3001",
        "order_id": "ord_5001",
        "request_type": "NORMAL",
        "status": "REQUESTED",
        "reason_code": "no_longer_needed",
        "customer_notes": "Found it cheaper locally.",
        "order_status": "paid",
        "reviewed_by": null,
        "reviewed_at": null,
        "staff_reason_code": null,
        "staff_notes": null,
        "refund": null,
        "created_at": "2026-09-17T18:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| request_type | enum | `NORMAL` \| `PRE_ARRIVAL` — derived by the server from the order type. |
| status | enum | `REQUESTED` \| `APPROVED` \| `REJECTED`. |
| reason_code | enum | NORMAL: `mistake` \| `wrong_product` \| `wrong_quantity` \| `no_longer_needed` \| `delivery_too_long` \| `other`. PRE_ARRIVAL: `no_longer_wanted` \| `delivery_too_long` \| `other`. |
| order_status | enum | Order status — unchanged while REQUESTED. |
| staff_reason_code | enum \| null | See approve/reject. |
| refund | object \| null | Present after approval: `{ refund_id, status, gross_refund, fee_lines[], final_refund }`. |

---

### 2.7 `GET /dashboard/cancellation-requests`

**Cancellation review queue**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/cancellation-requests` |
| Auth | Staff `orders.fulfill` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional · default REQUESTED |  |
| request_type | enum · optional | `NORMAL` \| `PRE_ARRIVAL`. |
| location_id | string · optional | Narrowed to caller's locations. |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/cancellation-requests?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "cancellation_request_id": "cxl_3001",
        "order_id": "ord_5001",
        "request_type": "NORMAL",
        "status": "REQUESTED",
        "reason_code": "no_longer_needed",
        "customer_notes": "Found it cheaper locally.",
        "order_status": "paid",
        "reviewed_by": null,
        "reviewed_at": null,
        "staff_reason_code": null,
        "staff_notes": null,
        "refund": null,
        "created_at": "2026-09-17T18:00:00Z",
        "order_placed_at": "2026-09-17T16:00:00Z",
        "window_expires_at": "2026-09-18T16:00:00Z",
        "any_line_picked": false,
        "customer_name": "John Smith"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].window_expires_at | ISO 8601 \| null | Normal orders. |
| items[].any_line_picked | boolean |  |

---

### 2.8 `POST /dashboard/orders/{orderId}/cancellation-requests/{requestId}/approve`

**Approve cancellation**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/orders/{orderId}/cancellation-requests/{requestId}/approve` |
| Purpose | Re-checks eligibility, then in one transaction: cancels the order, releases allocation (normal) or pre-arrival commitment, creates the refund, writes audit. Provider refund call runs after commit. No manager approval. |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |
| requestId | string |  |

**Request example**

```http
POST /api/v1/dashboard/orders/{orderId}/cancellation-requests/{requestId}/approve HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "staff_reason_code": "customer_request_valid",
  "staff_notes": "Confirmed within window and not picked."
}
```

No fee override field — fees come from configuration (cancellation_fee currently $0).

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| staff_reason_code | enum · required | NORMAL (PROPOSED): `customer_request_valid` \| `other_authorized`. PRE_ARRIVAL: `supplier_failure` \| `discontinued` \| `material_delay` \| `damaged_or_incorrect` \| `other_authorized`. |
| staff_notes | string · required for PRE_ARRIVAL, optional for NORMAL |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "cancellation_request_id": "cxl_3001",
    "order_id": "ord_5001",
    "request_type": "NORMAL",
    "status": "APPROVED",
    "reason_code": "no_longer_needed",
    "customer_notes": "Found it cheaper locally.",
    "order_status": "cancelled",
    "reviewed_by": "staff_02",
    "reviewed_at": "2026-09-17T19:00:00Z",
    "staff_reason_code": "customer_request_valid",
    "staff_notes": null,
    "refund": {
      "refund_id": "ref_0901",
      "status": "pending",
      "gross_refund": {
        "amount_minor": 13592,
        "currency": "USD"
      },
      "fee_lines": [
        {
          "fee_type": "cancellation_fee",
          "amount": {
            "amount_minor": 0,
            "currency": "USD"
          }
        }
      ],
      "final_refund": {
        "amount_minor": 13592,
        "currency": "USD"
      }
    },
    "created_at": "2026-09-17T18:00:00Z",
    "inventory_released": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| request_type | enum | `NORMAL` \| `PRE_ARRIVAL` — derived by the server from the order type. |
| status | enum | `REQUESTED` \| `APPROVED` \| `REJECTED`. |
| reason_code | enum | NORMAL: `mistake` \| `wrong_product` \| `wrong_quantity` \| `no_longer_needed` \| `delivery_too_long` \| `other`. PRE_ARRIVAL: `no_longer_wanted` \| `delivery_too_long` \| `other`. |
| order_status | enum | Order status — unchanged while REQUESTED. |
| staff_reason_code | enum \| null | See approve/reject. |
| refund | object \| null | Present after approval: `{ refund_id, status, gross_refund, fee_lines[], final_refund }`. |
| inventory_released | boolean |  |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `CANCELLATION_NOT_ELIGIBLE` | 422 | A line was picked since the request |
| `INVALID_STATE_TRANSITION` | 409 | Request not REQUESTED |
| `VALIDATION_ERROR` | 422 | Bad reason code or missing notes |
| `FORBIDDEN` | 403 | Location scope |
| `PAYMENT_RECONCILIATION_REQUIRED` | 409 | Payment state is ambiguous; staff reconciliation is required. |

---

### 2.9 `POST /dashboard/orders/{orderId}/cancellation-requests/{requestId}/reject`

**Reject cancellation**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/orders/{orderId}/cancellation-requests/{requestId}/reject` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |
| requestId | string |  |

**Request example**

```http
POST /api/v1/dashboard/orders/{orderId}/cancellation-requests/{requestId}/reject HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "staff_reason_code": "window_expired",
  "staff_notes": "Order already picked this morning."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| staff_reason_code | enum · required | (PROPOSED) `window_expired` \| `already_picked` \| `no_valid_exception` \| `other`. |
| staff_notes | string · optional | Shown to the customer in the rejection notice. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "cancellation_request_id": "cxl_3001",
    "order_id": "ord_5001",
    "request_type": "NORMAL",
    "status": "REJECTED",
    "reason_code": "no_longer_needed",
    "customer_notes": "Found it cheaper locally.",
    "order_status": "paid",
    "reviewed_by": "staff_02",
    "reviewed_at": "2026-09-17T19:00:00Z",
    "staff_reason_code": "window_expired",
    "staff_notes": null,
    "refund": null,
    "created_at": "2026-09-17T18:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| request_type | enum | `NORMAL` \| `PRE_ARRIVAL` — derived by the server from the order type. |
| status | enum | `REQUESTED` \| `APPROVED` \| `REJECTED`. |
| reason_code | enum | NORMAL: `mistake` \| `wrong_product` \| `wrong_quantity` \| `no_longer_needed` \| `delivery_too_long` \| `other`. PRE_ARRIVAL: `no_longer_wanted` \| `delivery_too_long` \| `other`. |
| order_status | enum | Order status — unchanged while REQUESTED. |
| staff_reason_code | enum \| null | See approve/reject. |
| refund | object \| null | Present after approval: `{ refund_id, status, gross_refund, fee_lines[], final_refund }`. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_STATE_TRANSITION` | 409 | Named command not allowed from the resource's current state. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 2.10 `POST /orders/{orderId}/return-attachments`

**Upload return photo**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/orders/{orderId}/return-attachments` |
| Auth | Owner |
| Status | PROPOSED |
| CSRF header | Required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
POST /api/v1/orders/{orderId}/return-attachments HTTP/1.1
Content-Type: multipart/form-data; boundary=...
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>

{
  "file": "(binary, multipart/form-data)"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| file | file · required | `image/jpeg` \| `image/png` \| `image/heic`; max size PENDING. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "attachment_id": "rat_101",
    "content_type": "image/jpeg",
    "expires_unused_at": "2026-09-21T09:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| attachment_id | string | Reference it in POST /orders/{orderId}/returns. |
| expires_unused_at | ISO 8601 | Unreferenced uploads are deleted after this. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `VALIDATION_ERROR` | 422 | Wrong type or too large |

---

### 2.11 `POST /orders/{orderId}/returns`

**Request return**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/orders/{orderId}/returns` |
| Auth | Owner |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
POST /api/v1/orders/{orderId}/returns HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "items": [
    {
      "order_line_id": "ol_1001",
      "quantity": 1,
      "reason": "damaged_or_broken",
      "notes": "Bottle arrived broken."
    }
  ],
  "attachment_ids": [
    "rat_101"
  ],
  "requested_resolution": "original_tender"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| items[].order_line_id | string · required |  |
| items[].quantity | integer · required | ≤ purchased − already returned. |
| items[].reason | enum · required | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` (`leaking_seal` hidden until confirmed). |
| items[].notes | string · optional |  |
| attachment_ids[] | string[] · required when any reason is damaged_or_broken |  |
| requested_resolution | enum · optional · default original_tender | `original_tender` \| `store_credit`. |

**Response — created (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "return_id": "ret_2001",
    "order_id": "ord_5001",
    "origin": "customer",
    "status": "requested",
    "refund_status": "not_issued",
    "requested_resolution": "original_tender",
    "lines": [
      {
        "return_line_id": "rl_01",
        "order_line_id": "ol_1001",
        "quantity": 1,
        "reason": "damaged_or_broken",
        "notes": "Bottle arrived broken.",
        "inspection": null
      }
    ],
    "attachments": [
      {
        "attachment_id": "rat_101",
        "url": "(signed, short-lived)",
        "content_type": "image/jpeg"
      }
    ],
    "created_at": "2026-09-20T09:00:00Z",
    "review_required": true
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| origin | enum | `customer` \| `staff` \| `system_failed_delivery`. |
| status | enum | `requested` \| `approved` \| `rejected` \| `received` \| `inspected` \| `completed`. |
| refund_status | enum | `not_issued` \| `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| requested_resolution | enum | `original_tender` \| `store_credit`. |
| lines[].reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` (system only) \| `leaking_seal` (pending confirmation). |
| lines[].inspection | object \| null | `{ condition, disposition, refund_eligible, notes, inspected_by, inspected_at }`. |
| attachments[] | object[] | Photo evidence (required for damaged_or_broken). |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `RETURN_WINDOW_EXPIRED` | 422 | > 30 days from delivery/pickup |
| `RETURN_NOT_ELIGIBLE` | 422 | Not delivered yet, pre-arrival without exception, image-only claim |
| `VALIDATION_ERROR` | 422 | Missing photo or bad quantity |
| `FORBIDDEN` | 403 | Authenticated, but missing permission, location scope, manager approval, or a valid webhook signature. |

---

### 2.12 `GET /orders/{orderId}/returns`

**Returns for an order**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/orders/{orderId}/returns` |
| Auth | Owner or staff |
| Status | CONFIRMED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
GET /api/v1/orders/{orderId}/returns HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "return_id": "ret_2001",
        "order_id": "ord_5001",
        "origin": "customer",
        "status": "requested",
        "refund_status": "not_issued",
        "requested_resolution": "original_tender",
        "lines": [
          {
            "return_line_id": "rl_01",
            "order_line_id": "ol_1001",
            "quantity": 1,
            "reason": "damaged_or_broken",
            "notes": "Bottle arrived broken.",
            "inspection": null
          }
        ],
        "attachments": [
          {
            "attachment_id": "rat_101",
            "url": "(signed, short-lived)",
            "content_type": "image/jpeg"
          }
        ],
        "created_at": "2026-09-20T09:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| origin | enum | `customer` \| `staff` \| `system_failed_delivery`. |
| status | enum | `requested` \| `approved` \| `rejected` \| `received` \| `inspected` \| `completed`. |
| refund_status | enum | `not_issued` \| `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| requested_resolution | enum | `original_tender` \| `store_credit`. |
| lines[].reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` (system only) \| `leaking_seal` (pending confirmation). |
| lines[].inspection | object \| null | `{ condition, disposition, refund_eligible, notes, inspected_by, inspected_at }`. |
| attachments[] | object[] | Photo evidence (required for damaged_or_broken). |

---

### 2.13 `GET /dashboard/returns`

**Return review queue**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/returns` |
| Auth | Staff `orders.fulfill` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum · optional |  |
| reason | enum · optional |  |
| location_id | string · optional |  |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/returns?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "return_id": "ret_2001",
        "order_id": "ord_5001",
        "origin": "customer",
        "status": "requested",
        "refund_status": "not_issued",
        "requested_resolution": "original_tender",
        "lines": [
          {
            "return_line_id": "rl_01",
            "order_line_id": "ol_1001",
            "quantity": 1,
            "reason": "damaged_or_broken",
            "notes": "Bottle arrived broken.",
            "inspection": null
          }
        ],
        "attachments": [
          {
            "attachment_id": "rat_101",
            "url": "(signed, short-lived)",
            "content_type": "image/jpeg"
          }
        ],
        "created_at": "2026-09-20T09:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| origin | enum | `customer` \| `staff` \| `system_failed_delivery`. |
| status | enum | `requested` \| `approved` \| `rejected` \| `received` \| `inspected` \| `completed`. |
| refund_status | enum | `not_issued` \| `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| requested_resolution | enum | `original_tender` \| `store_credit`. |
| lines[].reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` (system only) \| `leaking_seal` (pending confirmation). |
| lines[].inspection | object \| null | `{ condition, disposition, refund_eligible, notes, inspected_by, inspected_at }`. |
| attachments[] | object[] | Photo evidence (required for damaged_or_broken). |

---

### 2.14 `GET /dashboard/returns/{returnId}`

**Return detail (staff)**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/returns/{returnId}` |
| Auth | Staff `orders.fulfill` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| returnId | string |  |

**Request example**

```http
GET /api/v1/dashboard/returns/{returnId} HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "return_id": "ret_2001",
    "order_id": "ord_5001",
    "origin": "customer",
    "status": "requested",
    "refund_status": "not_issued",
    "requested_resolution": "original_tender",
    "lines": [
      {
        "return_line_id": "rl_01",
        "order_line_id": "ol_1001",
        "quantity": 1,
        "reason": "damaged_or_broken",
        "notes": "Bottle arrived broken.",
        "inspection": null
      }
    ],
    "attachments": [
      {
        "attachment_id": "rat_101",
        "url": "(signed, short-lived)",
        "content_type": "image/jpeg"
      }
    ],
    "created_at": "2026-09-20T09:00:00Z",
    "order": {
      "id": "ord_5001",
      "delivered_at": "2026-09-19T15:00:00Z"
    },
    "policy_preview": {
      "restocking_fee_applies": false,
      "original_shipping_refundable": true
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| origin | enum | `customer` \| `staff` \| `system_failed_delivery`. |
| status | enum | `requested` \| `approved` \| `rejected` \| `received` \| `inspected` \| `completed`. |
| refund_status | enum | `not_issued` \| `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| requested_resolution | enum | `original_tender` \| `store_credit`. |
| lines[].reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` (system only) \| `leaking_seal` (pending confirmation). |
| lines[].inspection | object \| null | `{ condition, disposition, refund_eligible, notes, inspected_by, inspected_at }`. |
| attachments[] | object[] | Photo evidence (required for damaged_or_broken). |
| policy_preview | object | What the configured policy would do for these reasons (advisory). |

---

### 2.15 `POST /dashboard/returns/{returnId}/approve`

**Approve return**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/returns/{returnId}/approve` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| returnId | string |  |

**Request example**

```http
POST /api/v1/dashboard/returns/{returnId}/approve HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "notes": "Photo confirms breakage.",
  "return_method": "customer_ships"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| notes | string · optional |  |
| return_method | enum · optional | `customer_ships` \| `prepaid_label` \| `drop_off_in_store` (PROPOSED; sound-wine mechanism open). |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "return_id": "ret_2001",
    "status": "approved"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_RETURN_STATE` | 409 | Not `requested` |

---

### 2.16 `POST /dashboard/returns/{returnId}/reject`

**Reject return**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/returns/{returnId}/reject` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| returnId | string |  |

**Request example**

```http
POST /api/v1/dashboard/returns/{returnId}/reject HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "reason": "claim_not_supported",
  "notes": "No damage visible."
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| reason | enum · required | (PROPOSED) `claim_not_supported` \| `outside_policy` \| `final_sale` \| `other`. |
| notes | string · optional | Shown to customer. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "return_id": "ret_2001",
    "status": "rejected"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_RETURN_STATE` | 409 | Only from `requested` or `received` |

---

### 2.17 `POST /dashboard/returns/{returnId}/receive`

**Mark received**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/returns/{returnId}/receive` |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| returnId | string |  |

**Request example**

```http
POST /api/v1/dashboard/returns/{returnId}/receive HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "received_lines": [
    {
      "return_line_id": "rl_01",
      "quantity": 1
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| received_lines[] | object[] · optional | Defaults to all requested quantities. |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "return_id": "ret_2001",
    "status": "received",
    "received_at": "2026-09-20T10:00:00Z"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_RETURN_STATE` | 409 | Not `approved` |

---

### 2.18 `POST /dashboard/returns/{returnId}/inspect`

**Record inspection**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/returns/{returnId}/inspect` |
| Purpose | Records condition, disposition and refund eligibility ONLY. No money moves. `sellable_restock` adds on_hand stock. |
| Auth | Staff `orders.fulfill` |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| returnId | string |  |

**Request example**

```http
POST /api/v1/dashboard/returns/{returnId}/inspect HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "lines": [
    {
      "return_line_id": "rl_01",
      "condition": "broken",
      "disposition": "broken",
      "refund_eligible": true,
      "notes": "Consistent with claim."
    }
  ]
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].return_line_id | string · required |  |
| lines[].condition | enum · required | (PROPOSED) `sealed_sellable` \| `opened` \| `flawed` \| `damaged` \| `broken` \| `leaking` \| `wrong_item`. |
| lines[].disposition | enum · required | `sellable_restock` \| `damaged` \| `broken` \| `quarantine` \| `discarded` \| `return_to_supplier`. |
| lines[].refund_eligible | boolean · required |  |
| lines[].notes | string · optional |  |

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "return_id": "ret_2001",
    "status": "inspected",
    "refund_status": "not_issued",
    "lines": [
      {
        "return_line_id": "rl_01",
        "disposition": "broken",
        "refund_eligible": true,
        "inventory_movement_id": "mov_3301"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| lines[].inventory_movement_id | string | `return_restock` or non-sellable movement. |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `INVALID_RETURN_STATE` | 409 | Not `received` |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 2.19 `POST /dashboard/orders/{orderId}/refunds`

**Issue refund**

| Property | Value |
|---|---|
| Endpoint | `POST /api/v1/dashboard/orders/{orderId}/refunds` |
| Purpose | The only refund command (D-27). Calculated from the original order snapshot and configured fee rules. A successful refund referencing a return completes it. |
| Auth | Staff `pos.refund` + manager approval |
| Status | CONFIRMED |
| CSRF header | Required |
| Idempotency-Key | Required |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
POST /api/v1/dashboard/orders/{orderId}/refunds HTTP/1.1
Content-Type: application/json
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
X-XSRF-TOKEN: <token from XSRF-TOKEN cookie>
Idempotency-Key: 6f1c2a4e-8d3b-4f6a-9c1e-2b7d5e8f0a13

{
  "return_id": "ret_2001",
  "reason": "damaged_or_broken",
  "refund_tender": "original_tender",
  "manager_approval_id": "approval_1001"
}
```

**Request keys**

| Key | Type / allowed values | Description |
|---|---|---|
| return_id | string · optional | Required for return-based reasons. |
| rapid_ship_sla_record_id | string · required when reason=rapid_ship_sla_failure |  |
| reason | enum · required | See refund reason list. |
| refund_tender | enum · required | `original_tender` \| `store_credit`. |
| amount | Money · optional | Only for `rapid_ship_sla_failure` / `other_authorized` where the rule allows staff entry; otherwise computed. |
| manager_approval_id | string · required | From POST /dashboard/manager-approvals; action `refund`. |

**Response — success — damaged/broken (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "refund_id": "ref_1001",
    "order_id": "ord_5001",
    "return_id": "ret_2001",
    "status": "completed",
    "reason": "damaged_or_broken",
    "gross_refund": {
      "amount_minor": 6275,
      "currency": "USD"
    },
    "fee_lines": [],
    "refunded_shipping": {
      "amount_minor": 600,
      "currency": "USD"
    },
    "final_refund": {
      "amount_minor": 6875,
      "currency": "USD"
    },
    "tender_allocation": [
      {
        "tender_type": "card",
        "amount": {
          "amount_minor": 6875,
          "currency": "USD"
        }
      }
    ],
    "return_status": "completed"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` \| `leaking_seal` \| `rapid_ship_sla_failure` \| `pre_arrival_exception` \| `cancellation` (system) \| `other_authorized`. |
| gross_refund | Money object `{ amount_minor: integer, currency: "USD" }` | From the original order snapshot. |
| fee_lines[].fee_type | enum | `cancellation_fee` \| `restocking_fee` \| `non_refundable_payment_fee` \| `original_shipping_fee` \| `return_shipping_fee` \| `failed_delivery_charges` \| `other_applicable_fee`. |
| fee_lines[].basis | Money \| absent | Base used for percentage fees (restocking: net product after discounts, excl. tax & shipping). |
| fee_lines[].rate_bps | integer \| absent | Basis points; 1500 = 15%. |
| fee_lines[].amount | Money object `{ amount_minor: integer, currency: "USD" }` | Deducted amount. |
| refunded_shipping | Money object `{ amount_minor: integer, currency: "USD" }` | Original shipping returned to the customer. |
| final_refund | Money object `{ amount_minor: integer, currency: "USD" }` | gross + refunded_shipping − fee lines. |
| tender_allocation[] | object[] | `{ tender_type: card\|cash\|gift_card\|store_credit, amount }`. |
| return_status | enum \| absent | Return becomes `completed` only when the refund `status` is `completed`. |

**Response — success — sound wine (15% restocking) (201)**

```http
HTTP/1.1 201 Created

{
  "data": {
    "refund_id": "ref_1002",
    "status": "completed",
    "reason": "sound_no_defect",
    "gross_refund": {
      "amount_minor": 6000,
      "currency": "USD"
    },
    "fee_lines": [
      {
        "fee_type": "restocking_fee",
        "basis": {
          "amount_minor": 6000,
          "currency": "USD"
        },
        "rate_bps": 1500,
        "amount": {
          "amount_minor": 900,
          "currency": "USD"
        }
      }
    ],
    "refunded_shipping": {
      "amount_minor": 0,
      "currency": "USD"
    },
    "final_refund": {
      "amount_minor": 5100,
      "currency": "USD"
    },
    "return_status": "completed"
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Errors**

| Code | HTTP | When |
|---|---|---|
| `REFUND_APPROVAL_REQUIRED` | 403 | Missing/expired/mismatched approval |
| `REFUND_ALREADY_ISSUED` | 409 | Refund for this scope was already issued. |
| `INVALID_RETURN_STATE` | 409 | Return not `inspected` |
| `PAYMENT_RECONCILIATION_REQUIRED` | 409 | Payment state is ambiguous; staff reconciliation is required. |
| `PROVIDER_UNAVAILABLE` | 503 | External provider unreachable or not configured; safe to retry later. |
| `VALIDATION_ERROR` | 422 | Request failed validation. `details.fields` maps field → messages. |

---

### 2.20 `GET /dashboard/orders/{orderId}/refunds`

**Refunds for an order**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/orders/{orderId}/refunds` |
| Auth | Staff `pos.refund` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
GET /api/v1/dashboard/orders/{orderId}/refunds HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "refund_id": "ref_1001",
        "status": "completed",
        "reason": "damaged_or_broken",
        "final_refund": {
          "amount_minor": 6875,
          "currency": "USD"
        },
        "created_at": "2026-09-21T10:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `pending` \| `completed` \| `failed` \| `reconciliation_required`. |
| reason | enum | `flawed_wine` \| `not_as_listed` \| `damaged_or_broken` \| `sound_no_defect` \| `failed_delivery` \| `leaking_seal` \| `rapid_ship_sla_failure` \| `pre_arrival_exception` \| `cancellation` (system) \| `other_authorized`. |

---

### 2.21 `GET /dashboard/orders`

**Order list (staff)**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/orders` |
| Purpose | Needed by the dashboard Orders screen; no source defined it. |
| Auth | Staff `orders.fulfill` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Query parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| status / payment_status / fulfillment_status | enum · optional |  |
| channel | enum · optional | `web` \| `pos`. |
| order_type | enum · optional |  |
| rapid_ship | boolean · optional |  |
| location_id | string · optional |  |
| q | string · optional | Confirmation number, email or phone. |
| from / to | date · optional | Store-local dates. |
| page | integer · optional · default 1 | 1-based page number. |
| per_page | integer · optional · default 24 · max 100 | Items per page. Above 100 → VALIDATION_ERROR (D-26, reject-vs-clamp still open). |
| sort | string · optional | Field name, prefix `-` for descending, e.g. `-created_at`. |

**Request example**

```http
GET /api/v1/dashboard/orders?page=1&per_page=24 HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "items": [
      {
        "id": "ord_5001",
        "confirmation_number": "OW-5001",
        "channel": "web",
        "order_type": "normal",
        "status": "paid",
        "fulfillment_status": "unassigned",
        "rapid_ship": true,
        "customer_name": "John Smith",
        "total": {
          "amount_minor": 13592,
          "currency": "USD"
        },
        "placed_at": "2026-09-17T16:00:00Z"
      }
    ],
    "pagination": {
      "page": 1,
      "per_page": 24,
      "total": 1,
      "last_page": 1
    }
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

---

### 2.22 `GET /dashboard/orders/{orderId}`

**Order detail (staff)**

| Property | Value |
|---|---|
| Endpoint | `GET /api/v1/dashboard/orders/{orderId}` |
| Auth | Staff `orders.fulfill` |
| Status | PROPOSED |
| CSRF header | Not required |
| Idempotency-Key | Not used |
| Rate limited | No |

**Path parameters**

| Key | Type / allowed values | Description |
|---|---|---|
| orderId | string |  |

**Request example**

```http
GET /api/v1/dashboard/orders/{orderId} HTTP/1.1
Cookie: access_token=<jwt>; refresh_token=<opaque>; XSRF-TOKEN=<token>
```

**Response — success (200)**

```http
HTTP/1.1 200 OK

{
  "data": {
    "id": "ord_5001",
    "confirmation_number": "OW-5001",
    "channel": "web",
    "order_type": "normal",
    "status": "paid",
    "payment_status": "captured",
    "fulfillment_status": "unassigned",
    "placed_at": "2026-09-17T16:00:00Z",
    "cancellation_eligible_until": "2026-09-18T16:00:00Z",
    "customer": {
      "id": "usr_101",
      "email": "customer@example.com",
      "phone": "+15185550100"
    },
    "lines": [
      {
        "id": "ol_1001",
        "product_id": "prod_101",
        "sku": "CAB-2023-750",
        "name": "Example Cabernet 2023 750ml",
        "sell_unit": "bottle",
        "quantity": 2,
        "unit_price": {
          "amount_minor": 6275,
          "currency": "USD"
        },
        "discount": {
          "amount_minor": 0,
          "currency": "USD"
        },
        "tax": {
          "amount_minor": 502,
          "currency": "USD"
        },
        "line_total": {
          "amount_minor": 12550,
          "currency": "USD"
        },
        "fulfillment_group_id": "ful_9001",
        "pre_arrival": false
      }
    ],
    "totals": {
      "subtotal": {
        "amount_minor": 12550,
        "currency": "USD"
      },
      "discount": {
        "amount_minor": 0,
        "currency": "USD"
      },
      "tax": {
        "amount_minor": 1004,
        "currency": "USD"
      },
      "shipping": {
        "amount_minor": 600,
        "currency": "USD"
      },
      "total": {
        "amount_minor": 13592,
        "currency": "USD"
      }
    },
    "shipping_address": {
      "line1": "100 Main Street",
      "line2": "Suite 4",
      "city": "Albany",
      "state": "NY",
      "postal_code": "12201",
      "country": "US"
    },
    "ship_free_12_applied": false,
    "rapid_ship": true,
    "fulfillment_groups": [
      {
        "id": "ful_9001",
        "kind": "rapid_ship",
        "mode": "shipping",
        "location_id": "loc_albany",
        "status": "unassigned",
        "shipments": [],
        "rapid_ship_sla": {
          "dispatch_deadline_at": "2026-09-17T23:59:00Z",
          "delivery_target_at": "2026-09-19T23:59:00Z",
          "sla_met": null
        }
      }
    ],
    "pre_arrival": null,
    "open_cancellation_request_id": null,
    "refunds": [],
    "payments": [
      {
        "payment_reference": "pay_9001",
        "status": "captured",
        "amount": {
          "amount_minor": 13592,
          "currency": "USD"
        }
      }
    ],
    "cancellation_requests": [],
    "returns": [],
    "audit": [
      {
        "event": "order.placed",
        "at": "2026-09-17T16:00:00Z"
      }
    ]
  },
  "request_id": "req_01J9Z8",
  "correlation_id": "cor_01J9Z8"
}
```

**Response keys**

| Key | Type / allowed values | Description |
|---|---|---|
| status | enum | `draft` \| `pending_payment` \| `payment_failed`* \| `expired`* \| `paid` \| `partially_fulfilled` \| `fulfilled` \| `cancelled` \| `partially_refunded` \| `refunded` (*PROPOSED, D-30). |
| payment_status | enum | `created` \| `pending` \| `authorized` \| `captured` \| `failed` \| `reversed` \| `partially_refunded` \| `refunded` \| `disputed`. |
| channel | enum | `web` \| `pos`. |
| order_type | enum | `normal` \| `pre_arrival`. |
| cancellation_eligible_until | ISO 8601 \| null | Normal orders only; also void once any line is picked. |
| lines[] | OrderLine | Immutable snapshot: sku, name, sell_unit, prices, tax, promotion versions. |
| totals.* | Money object `{ amount_minor: integer, currency: "USD" }` | Snapshot at placement. |
| ship_free_12_applied | boolean | Whether 12 Ship Free waived shipping on this order. |
| rapid_ship | boolean | Order contains a Rapid Ship fulfillment group. |
| fulfillment_groups[].kind | enum | `rapid_ship` \| `standard` \| `pre_arrival`. |
| fulfillment_groups[].status | enum | `unassigned` \| `picking` \| `packed` \| `ready_for_pickup` \| `handed_off` \| `shipped` \| `out_for_delivery` \| `delivered` \| `failed` \| `returned` \| `expired` \| `cancelled`. |
| fulfillment_groups[].rapid_ship_sla | object \| null | `dispatch_deadline_at`, `delivery_target_at`, `sla_met` (null while in progress). |
| pre_arrival | object \| null | Pre-arrival orders: `{ estimated_delivery, revised_arrival_from, revised_arrival_to, commitment_status: committed\|allocated\|cancelled, terms_version }`. |
| open_cancellation_request_id | string \| null |  |
| refunds[] | object[] | `{ refund_id, status, final_refund: Money, created_at }`. |
| audit[] | object[] | Order timeline (PII masked). |

**Errors**

| Code | HTTP | When |
|---|---|---|
| `FORBIDDEN` | 403 | Location scope |
| `RESOURCE_NOT_FOUND` | 404 | Resource doesn't exist or isn't visible to the caller (never reveals existence). |

---
