# Rental Platform API - Technical Documentation & Frontend Implementation Guide

> **Base URL:** `http://localhost:8000/api/v1`
> **API Version:** v1
> **Auth:** Laravel Sanctum (Bearer Token)
> **Multi-Tenancy:** All tenant routes require `X-Tenant-ID` header

---

## Table of Contents

- [Architecture Overview](#architecture-overview)
- [Authentication & Headers](#authentication--headers)
- [Response Format](#response-format)
- [Error Handling](#error-handling)
- [Rate Limiting](#rate-limiting)
- [Roles & Permissions](#roles--permissions)
- [M1: Central (Tenant Registration)](#m1-central-tenant-registration)
- [M2: Health Check](#m2-health-check)
- [M2: Auth (Login, Logout, Password Reset)](#m2-auth)
- [M3: Catalog (Products, Categories, Media, Pricing Rules)](#m3-catalog)
- [M4: Assets & Maintenance](#m4-assets--maintenance)
- [M5: Availability](#m5-availability)
- [M6: Bookings](#m6-bookings)
- [M7: Payments](#m7-payments)
- [M8: Inspections](#m8-inspections)
- [M9: Notifications](#m9-notifications)
- [M10: Profile & Staff Management](#m10-profile--staff-management)
- [M11: Delivery & Zones](#m11-delivery--zones)
- [M12: Reports](#m12-reports)
- [M13: Billing & Subscriptions](#m13-billing--subscriptions)
- [M14: Tenant Settings](#m14-tenant-settings)
- [M15: Search](#m15-search)
- [M17: Coupons](#m17-coupons)
- [M18: Reviews](#m18-reviews)
- [M19: Integrations & Webhooks](#m19-integrations--webhooks)

---

## Architecture Overview

### Multi-Tenant SaaS

This is a **multi-tenant rental equipment platform**. Each tenant (store) is isolated via database-level separation using [Stancl Tenancy](https://tenancyforlaravel.com/). The tenant is identified by the `X-Tenant-ID` header on every request (except central routes).

### Frontend Implementation Notes

- **State Management:** Store `token`, `tenant_id`, and `user` object after login/register.
- **Axios/Fetch Interceptor:** Attach `Authorization: Bearer {token}` and `X-Tenant-ID: {tenant_id}` to every request.
- **Role-Based UI:** Use the `role` field from the user object (`owner`, `staff`, `customer`) to conditionally render admin panels, staff views, or customer dashboards.
- **Permissions:** Staff users have granular permissions returned in the `permissions` array. Use these to show/hide specific UI elements.

### Tech Stack

| Layer | Technology |
|-------|-----------|
| Backend | Laravel 13 (PHP 8.4) |
| Auth | Laravel Sanctum |
| Multi-Tenancy | Stancl Tenancy |
| Payments | Stripe |
| Database | MySQL (per-tenant) |
| Cache/Queue | Redis |
| File Storage | S3-compatible |

---

## Authentication & Headers

### Required Headers (All Requests)

```
Content-Type: application/json
Accept: application/json
```

### Tenant Routes (Additional)

```
X-Tenant-ID: {tenant_uuid}
```

### Protected Routes (Additional)

```
Authorization: Bearer {sanctum_token}
```

### Frontend Implementation

```javascript
// axios interceptor example
axios.interceptors.request.use((config) => {
  const token = localStorage.getItem('token');
  const tenantId = localStorage.getItem('tenant_id');

  if (token) config.headers.Authorization = `Bearer ${token}`;
  if (tenantId) config.headers['X-Tenant-ID'] = tenantId;

  config.headers['Content-Type'] = 'application/json';
  config.headers['Accept'] = 'application/json';

  return config;
});
```

---

## Response Format

### Success Response

```json
{
  "success": true,
  "message": "Operation successful",
  "data": { ... }
}
```

### Paginated Response

```json
{
  "success": true,
  "data": {
    "data": [ ... ],
    "meta": {
      "current_page": 1,
      "last_page": 5,
      "per_page": 20,
      "total": 100
    }
  }
}
```

### Error Response

```json
{
  "success": false,
  "message": "Error description",
  "code": "ERROR_CODE",
  "errors": {
    "field_name": ["Validation error message"]
  }
}
```

### Frontend: Global Error Handler

```javascript
axios.interceptors.response.use(
  (response) => response.data,
  (error) => {
    const { status, data } = error.response;

    if (status === 401) {
      // Token expired -> redirect to login
      localStorage.removeItem('token');
      window.location.href = '/login';
    }
    if (status === 422) {
      // Validation errors -> display per-field errors from data.errors
    }
    if (status === 403) {
      // Forbidden -> show "no permission" toast
    }
    if (status === 429) {
      // Rate limited -> show "too many requests" message
    }

    return Promise.reject(data);
  }
);
```

---

## Rate Limiting

| Context | Limit | Header |
|---------|-------|--------|
| Auth endpoints (login, register, forgot-password) | 10 req/min | `X-RateLimit-Remaining` |
| Public endpoints (availability) | 60 req/min | `X-RateLimit-Remaining` |
| Authenticated endpoints | 120 req/min | `X-RateLimit-Remaining` |

---

## Roles & Permissions

### Roles

| Role | Description |
|------|-------------|
| `owner` | Tenant owner. Full access to everything. |
| `staff` | Invited team member. Access limited by permissions. |
| `customer` | Self-registered customer. Can browse, book, review. |

### Staff Permissions

Permissions are returned in the `permissions` array of the user object. Use for frontend feature-gating:

- `manage_staff` - Staff CRUD
- `manage_settings` - Tenant settings
- `manage_products` - Product CRUD
- `manage_bookings` - Booking operations
- `manage_assets` - Asset management
- `view_reports` - Report access
- `manage_customers` - Customer management
- `manage_deliveries` - Delivery operations

### Frontend: Permission Guard

```javascript
// composable or utility
function hasPermission(user, permission) {
  if (user.role === 'owner') return true;
  return user.permissions?.includes(permission);
}

// usage in component
if (hasPermission(user, 'manage_products')) {
  // show product management UI
}
```

---

## M1: Central (Tenant Registration)

> **No tenant header required.** This is a central route for onboarding new stores.

### `POST /v1/central/register`

Register a new tenant (store) and create the owner account.

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `store_name` | string | Yes | min:3, max:100 |
| `email` | string | Yes | valid email |
| `password` | string | Yes | min:8 |
| `password_confirmation` | string | Yes | must match password |
| `phone` | string | No | max:20 |
| `currency` | string | No | in: USD, EUR, GBP, BDT |
| `timezone` | string | No | max:50 |

**Example Request:**

```json
{
  "store_name": "Adventure Gear Rentals",
  "email": "owner@example.com",
  "password": "securepass123",
  "password_confirmation": "securepass123",
  "phone": "+1234567890",
  "currency": "USD",
  "timezone": "America/New_York"
}
```

**Response (201 Created):**

```json
{
  "success": true,
  "message": "Store registered successfully",
  "data": {
    "tenant": {
      "id": "uuid-here",
      "name": "Adventure Gear Rentals"
    },
    "user": {
      "id": 1,
      "name": "owner@example.com",
      "email": "owner@example.com",
      "role": "owner"
    },
    "token": "1|abc123...",
    "token_type": "Bearer"
  }
}
```

**Frontend Implementation:**

1. After successful registration, store `data.token` and `data.tenant.id` in localStorage.
2. Set these as default headers for all subsequent API calls.
3. Redirect to the store setup/dashboard page.

```javascript
const register = async (formData) => {
  const res = await axios.post('/v1/central/register', formData);
  localStorage.setItem('token', res.data.token);
  localStorage.setItem('tenant_id', res.data.tenant.id);
  localStorage.setItem('user', JSON.stringify(res.data.user));
  router.push('/dashboard');
};
```

---

## M2: Health Check

> **Public routes.** Require `X-Tenant-ID` header but no auth.

### `GET /v1/health`

Quick health check. Use for uptime monitoring.

**Response (200):**

```json
{
  "status": "ok",
  "tenant": "uuid-or-central",
  "version": "1.0.0",
  "timestamp": "2026-03-29T10:00:00.000000Z"
}
```

### `GET /v1/health/detailed`

Detailed health check with subsystem status.

**Response (200 or 503):**

```json
{
  "status": "ok",
  "tenant": "uuid-or-central",
  "version": "1.0.0",
  "checks": {
    "database": { "status": "ok", "latency_ms": 2.3 },
    "redis": { "status": "ok", "latency_ms": 1.1 },
    "queue": { "status": "ok", "latency_ms": 0.8 },
    "tenant_database": { "status": "ok" }
  },
  "timestamp": "2026-03-29T10:00:00.000000Z"
}
```

> **Note:** Returns HTTP 503 if any check reports `"status": "error"` (overall status becomes `"degraded"`).

---

## M2: Auth

> **Tenant routes.** Require `X-Tenant-ID` header. Rate limited: **10 req/min**.

### `POST /v1/auth/login`

**Request Body:**

| Field | Type | Required |
|-------|------|----------|
| `email` | string | Yes |
| `password` | string | Yes |

**Example Request:**

```json
{
  "email": "owner@example.com",
  "password": "securepass123"
}
```

**Response (200):**

```json
{
  "success": true,
  "message": "Login successful",
  "data": {
    "user": {
      "id": 1,
      "name": "John Doe",
      "email": "owner@example.com",
      "role": "owner",
      "permissions": ["manage_staff", "manage_settings", "view_reports"]
    },
    "token": "2|xyz789...",
    "token_type": "Bearer"
  }
}
```

**Frontend Implementation:**

```javascript
const login = async (email, password) => {
  const res = await axios.post('/v1/auth/login', { email, password });
  localStorage.setItem('token', res.data.token);
  localStorage.setItem('user', JSON.stringify(res.data.user));

  // Route based on role
  if (res.data.user.role === 'customer') {
    router.push('/browse');
  } else {
    router.push('/dashboard');
  }
};
```

---

### `POST /v1/auth/logout`

> **Requires auth.** Invalidates the current token.

**Request Body:** None

**Response (200):**

```json
{
  "success": true,
  "message": "Logged out successfully",
  "data": null
}
```

---

### `GET /v1/auth/me`

> **Requires auth.** Returns the currently authenticated user's profile.

**Response (200):**

```json
{
  "success": true,
  "data": {
    "id": 1,
    "name": "John Doe",
    "email": "owner@example.com",
    "phone": "+1234567890",
    "role": "owner",
    "is_active": true,
    "permissions": ["manage_staff", "manage_settings"],
    "roles": ["owner"],
    "created_at": "2026-01-15T08:00:00.000000Z"
  }
}
```

**Frontend:** Call on app mount to validate the stored token and hydrate user state.

---

### `POST /v1/auth/forgot-password`

**Request Body:**

| Field | Type | Required |
|-------|------|----------|
| `email` | string | Yes |

**Response (200):**

```json
{
  "success": true,
  "message": "If that email exists, a reset token has been sent",
  "data": null
}
```

> **Security Note:** Always returns 200 regardless of whether email exists. Frontend should show a generic success message.

---

### `POST /v1/auth/reset-password`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | valid email |
| `token` | string | Yes | from reset email |
| `password` | string | Yes | min:8 |
| `password_confirmation` | string | Yes | must match |

**Response (200):**

```json
{
  "success": true,
  "message": "Password reset successfully",
  "data": null
}
```

---

### `POST /v1/auth/customer/register`

> **Public tenant route.** Rate limited: 10 req/min.

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:255 |
| `email` | string | Yes | valid email, max:255 |
| `password` | string | Yes | min:8 |
| `password_confirmation` | string | Yes | must match |
| `phone` | string | No | max:20 |

**Response (201):**

```json
{
  "success": true,
  "message": "Customer registered",
  "data": {
    "user": {
      "id": 5,
      "name": "Jane Customer",
      "email": "jane@example.com",
      "phone": null,
      "is_active": true,
      "created_at": "2026-03-29T10:00:00.000000Z"
    },
    "token": "3|abc..."
  }
}
```

---

## M3: Catalog

> **Tenant routes.** Require `X-Tenant-ID`. Most read endpoints are accessible to all authenticated users. Write operations require `owner` or `staff` role.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Product Listing / Browse | `GET /catalog/products`, `GET /catalog/categories` |
| Product Detail | `GET /catalog/products/{id}`, `GET /catalog/products/{id}/reviews` |
| Product Search | `GET /catalog/search` |
| Featured Products | `GET /catalog/products/featured` |
| Admin: Product Management | All product CRUD endpoints |
| Admin: Category Management | All category CRUD endpoints |
| Admin: Pricing Rules | All pricing rule endpoints |
| Admin: Media Upload | All media endpoints |

---

### Categories

#### `GET /v1/catalog/categories`

List all categories. Returns a flat list (with nested children if loaded).

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Default | Description |
|-------|------|---------|-------------|
| `include_inactive` | boolean | false | Include inactive categories (admin only) |
| `with_products_count` | boolean | false | Include product count per category |
| `parent_id` | integer | - | Filter by parent category |

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "Power Tools",
      "slug": "power-tools",
      "description": "Heavy-duty power tools for rent",
      "image": null,
      "sort_order": 0,
      "is_active": true,
      "parent_id": null,
      "children": [],
      "products_count": 15,
      "created_at": "2026-01-15T08:00:00.000000Z"
    }
  ]
}
```

---

#### `POST /v1/catalog/categories`

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:100 |
| `parent_id` | integer | No | must exist in categories |
| `description` | string | No | max:1000 |
| `sort_order` | integer | No | min:0 |
| `is_active` | boolean | No | defaults to true |

**Response (201):**

```json
{
  "success": true,
  "message": "Category created",
  "data": {
    "id": 2,
    "name": "Hand Tools",
    "slug": "hand-tools",
    "description": "Manual hand tools",
    "image": null,
    "sort_order": 1,
    "is_active": true,
    "parent_id": null,
    "children": [],
    "created_at": "2026-03-29T10:00:00.000000Z"
  }
}
```

---

#### `GET /v1/catalog/categories/{id}`

**Auth:** Any authenticated user

**Response (200):** Single `CategoryResource` (same shape as list item).

---

#### `PATCH /v1/catalog/categories/{id}`

**Auth:** `owner` or `staff`

**Request Body:** Same fields as create, all optional.

**Response (200):** Updated `CategoryResource`.

---

#### `DELETE /v1/catalog/categories/{id}`

**Auth:** `owner` only

**Response (200):**

```json
{ "success": true, "message": "Category deleted", "data": null }
```

---

#### `POST /v1/catalog/categories/reorder`

**Auth:** `owner` or `staff`

**Request Body:**

```json
{
  "items": [
    { "id": 1, "sort_order": 0 },
    { "id": 2, "sort_order": 1 },
    { "id": 3, "sort_order": 2 }
  ]
}
```

**Response (200):**

```json
{ "success": true, "message": "Categories reordered", "data": null }
```

---

#### `GET /v1/catalog/categories/{id}/products`

Get products within a specific category.

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Default |
|-------|------|---------|
| `per_page` | integer | 20 |

**Response (200):** Paginated `ProductResource` collection.

---

### Products

#### `GET /v1/catalog/products`

List products with filtering, sorting, and pagination.

**Auth:** Any authenticated user. Customers only see `published` products; owners/staff can filter by status.

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `category_id` | integer | Filter by single category |
| `category_ids` | array | Filter by multiple categories (`category_ids[]=1&category_ids[]=2`) |
| `featured` | boolean | Show only featured products |
| `search` | string | Search in name/description |
| `min_price` | integer | Minimum price_per_day (cents) |
| `max_price` | integer | Maximum price_per_day (cents) |
| `sort` | string | Sort field |
| `availability_date` | date | Filter by availability on date |
| `status` | string | Product status (owner/staff only) |
| `per_page` | integer | Items per page (default 20) |
| `page` | integer | Page number |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "name": "DeWalt Circular Saw",
        "slug": "dewalt-circular-saw",
        "sku": "DW-CS-001",
        "short_description": "Professional 7-1/4\" circular saw",
        "description": "Full description here...",
        "category": {
          "id": 1,
          "name": "Power Tools",
          "slug": "power-tools"
        },
        "pricing": {
          "price_per_day": 2500,
          "price_per_week": 15000,
          "price_per_month": 50000,
          "security_deposit": 10000,
          "currency": "USD"
        },
        "rental_rules": {
          "minimum_rental_days": 1,
          "maximum_rental_days": 90,
          "buffer_days": 1
        },
        "inventory": {
          "total_assets": 5,
          "available_assets": 3,
          "is_available": true
        },
        "status": "published",
        "is_featured": true,
        "requires_id_verification": false,
        "specifications": { "power": "15A", "blade": "7-1/4\"" },
        "rental_includes": ["Blade", "Carrying case"],
        "rental_excludes": ["Extra blades"],
        "terms": ["Must return clean"],
        "primary_image": {
          "id": 1,
          "url": "https://s3.../image.jpg",
          "is_primary": true
        },
        "media": [],
        "pricing_rules": [],
        "average_rating": 4.5,
        "review_count": 12,
        "created_at": "2026-01-15T08:00:00.000000Z",
        "updated_at": "2026-03-20T14:00:00.000000Z"
      }
    ],
    "meta": {
      "current_page": 1,
      "last_page": 3,
      "per_page": 20,
      "total": 45
    },
    "facets": {}
  }
}
```

> **Note:** All prices are in **cents** (integer). Display as `(price / 100).toFixed(2)`.

**Frontend: Price Display Helper**

```javascript
const formatPrice = (cents, currency = 'USD') => {
  return new Intl.NumberFormat('en-US', {
    style: 'currency', currency
  }).format(cents / 100);
};
```

---

#### `GET /v1/catalog/products/featured`

**Auth:** Any authenticated user | **Query:** `per_page` (default 20)

**Response (200):** Paginated `ProductResource` collection.

---

#### `GET /v1/catalog/products/{id}`

**Auth:** Any authenticated user

**Response (200):** Single `ProductResource` with all relations loaded.

---

#### `POST /v1/catalog/products`

**Auth:** `owner` or `staff` | **Plan limit:** `products`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:200 |
| `category_id` | integer | No | must exist in categories |
| `short_description` | string | No | max:500 |
| `description` | string | No | unlimited |
| `sku` | string | No | max:100 |
| `price_per_day` | integer | Yes | min:1 (cents) |
| `price_per_week` | integer | No | min:1 (cents) |
| `price_per_month` | integer | No | min:1 (cents) |
| `security_deposit` | integer | No | min:0 (cents) |
| `minimum_rental_days` | integer | No | min:1 |
| `maximum_rental_days` | integer | No | must be > minimum_rental_days |
| `buffer_days` | integer | No | min:0 |
| `requires_id_verification` | boolean | No | |
| `is_featured` | boolean | No | |
| `specifications` | object | No | free-form key-value |
| `rental_includes` | array | No | array of strings |
| `rental_excludes` | array | No | array of strings |
| `terms` | array | No | array of strings |

**Example Request:**

```json
{
  "name": "DeWalt Circular Saw",
  "category_id": 1,
  "short_description": "Professional 7-1/4\" circular saw",
  "price_per_day": 2500,
  "price_per_week": 15000,
  "security_deposit": 10000,
  "minimum_rental_days": 1,
  "maximum_rental_days": 30,
  "specifications": { "power": "15A", "blade_size": "7-1/4\"" },
  "rental_includes": ["Blade", "Carrying case"]
}
```

**Response (201):** `ProductResource`. Product starts in `draft` status.

> **Frontend:** New products are `draft`. Call `/publish` to make visible to customers.

---

#### `PATCH /v1/catalog/products/{id}`

**Auth:** `owner` or `staff` | **Body:** Same fields, all optional.

**Response (200):** Updated `ProductResource`.

---

#### `DELETE /v1/catalog/products/{id}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Product deleted", "data": null }`

---

#### `POST /v1/catalog/products/{id}/publish`

Transition product `draft` -> `published`.

**Auth:** `owner` or `staff` | **Body:** None

**Response (200):** `ProductResource` with `status: "published"`.

---

#### `POST /v1/catalog/products/{id}/archive`

Transition product to `archived`. Hides from customer view.

**Auth:** `owner` or `staff` | **Body:** None

**Response (200):** `ProductResource` with `status: "archived"`.

---

#### `GET /v1/catalog/products/{id}/calculate-price`

Calculate rental price for N days, applying pricing rules.

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Required | Validation |
|-------|------|----------|------------|
| `days` | integer | Yes | min:1, max:365 |

**Response (200):** Price calculation result object.

---

### Product Media

#### `POST /v1/catalog/products/{productId}/media`

Upload product image. **Content-Type: `multipart/form-data`**

**Auth:** `owner` or `staff`

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | jpeg, png, webp, gif; max 5MB |
| `is_primary` | boolean | No | |

**Response (201):**

```json
{
  "success": true,
  "data": {
    "id": 1,
    "url": "https://s3.../products/image.jpg",
    "file_name": "image.jpg",
    "mime_type": "image/jpeg",
    "file_size": 245760,
    "type": "image",
    "is_primary": true,
    "sort_order": 0
  }
}
```

**Frontend:**

```javascript
const uploadMedia = async (productId, file, isPrimary = false) => {
  const formData = new FormData();
  formData.append('file', file);
  if (isPrimary) formData.append('is_primary', '1');
  return axios.post(`/v1/catalog/products/${productId}/media`, formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  });
};
```

---

#### `POST /v1/catalog/products/{productId}/media/reorder`

**Auth:** `owner` or `staff` | **Body:** `{ "items": [{ "id": 1, "sort_order": 0 }, ...] }`

---

#### `POST /v1/catalog/products/{productId}/media/{mediaId}/set-primary`

**Auth:** `owner` or `staff` | **Body:** None

**Response (200):** `ProductMediaResource` with `is_primary: true`.

---

#### `DELETE /v1/catalog/products/{productId}/media/{mediaId}`

**Auth:** `owner` or `staff`

**Response (200):** `{ "success": true, "message": "Media deleted", "data": null }`

---

### Pricing Rules

Dynamic pricing rules that override the base `price_per_day`.

#### `GET /v1/catalog/products/{productId}/pricing-rules`

**Auth:** `owner` or `staff`

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "Weekend Premium",
      "type": "weekend",
      "price_per_day": 3500,
      "minimum_days": null,
      "valid_from": null,
      "valid_until": null,
      "is_active": true
    }
  ]
}
```

---

#### `POST /v1/catalog/products/{productId}/pricing-rules`

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:100 |
| `type` | string | Yes | in: `seasonal`, `weekend`, `minimum_days`, `bulk` |
| `price_per_day` | integer | Yes | min:1 (cents) |
| `minimum_days` | integer | No | min:1 |
| `valid_from` | date | No | YYYY-MM-DD |
| `valid_until` | date | No | must be after valid_from |
| `is_active` | boolean | No | |

**Pricing Rule Types:**

| Type | Behavior |
|------|----------|
| `seasonal` | Applied between `valid_from` and `valid_until` |
| `weekend` | Applied for weekend-overlapping rentals |
| `minimum_days` | Applied when rental >= `minimum_days` |
| `bulk` | Discount for long-duration rentals |

**Response (201):** `PricingRuleResource`.

---

#### `PATCH /v1/catalog/products/{productId}/pricing-rules/{ruleId}`

**Auth:** `owner` or `staff` | **Body:** Same fields, all optional.

---

#### `DELETE /v1/catalog/products/{productId}/pricing-rules/{ruleId}`

**Auth:** `owner` or `staff`

**Response (200):** `{ "success": true, "message": "Pricing rule deleted", "data": null }`

---

## M4: Assets & Maintenance

> **Tenant routes.** Assets are individual physical items (serial-tracked) belonging to a product. E.g., Product "DeWalt Circular Saw" may have 5 assets (units), each with its own serial number, condition, and maintenance history.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Admin: Asset List (per product) | `GET /assets/products/{productId}/assets` |
| Admin: Asset Detail | `GET /assets/{assetId}`, `GET /assets/{assetId}/activity` |
| Admin: Create Asset | `POST /assets/products/{productId}/assets` |
| Admin: Bulk Create | `POST /assets/products/{productId}/assets/bulk` |
| Admin: Maintenance Dashboard | `GET /assets/maintenance/upcoming` |
| Admin: Asset Maintenance | `GET /assets/{assetId}/maintenance` |

---

### Per-Product Asset Management

#### `GET /v1/assets/products/{productId}/assets`

List all assets for a product with filtering.

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `status` | string | Filter: available, reserved, rented, under_maintenance, damaged, retired, lost |
| `condition` | string | Filter: excellent, good, fair, poor, damaged |
| `is_active` | boolean | Active/inactive filter |
| `search` | string | Search in name, serial_number, barcode |
| `per_page` | integer | Items per page |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "product_id": 1,
        "product": {
          "id": 1,
          "name": "DeWalt Circular Saw",
          "slug": "dewalt-circular-saw"
        },
        "name": "DeWalt CS Unit #1",
        "serial_number": "DW-2024-001",
        "barcode": "BAR001",
        "qr_code": null,
        "status": {
          "value": "available",
          "label": "Available",
          "is_available_for_booking": true
        },
        "condition": {
          "value": "excellent",
          "label": "Excellent"
        },
        "condition_notes": null,
        "location": "Warehouse A - Shelf 3",
        "purchase_price": 35000,
        "purchase_date": "2025-06-15",
        "supplier": "DeWalt Direct",
        "warranty_expires_at": "2027-06-15",
        "is_under_warranty": true,
        "is_active": true,
        "internal_notes": null,
        "allowed_transitions": ["reserved", "under_maintenance", "retired"],
        "created_at": "2026-01-15T08:00:00.000000Z",
        "updated_at": "2026-03-20T14:00:00.000000Z"
      }
    ],
    "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 5 }
  }
}
```

> **Frontend Note:** The `allowed_transitions` array tells you which status buttons to show. The `status.is_available_for_booking` flag indicates if this asset can be assigned to new bookings.

---

#### `POST /v1/assets/products/{productId}/assets`

**Auth:** `owner` or `staff` | **Plan limit:** `assets`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:200 |
| `serial_number` | string | No | max:100, unique |
| `barcode` | string | No | max:100, unique |
| `qr_code` | string | No | max:100, unique |
| `purchase_price` | integer | No | min:0 (cents) |
| `purchase_date` | date | No | YYYY-MM-DD |
| `supplier` | string | No | max:200 |
| `warranty_expires_at` | date | No | must be after purchase_date |
| `condition` | string | No | in: excellent, good, fair, poor, damaged |
| `condition_notes` | string | No | max:1000 |
| `internal_notes` | string | No | max:2000 |
| `location` | string | No | max:200 |

**Response (201):** `AssetResource`.

---

#### `POST /v1/assets/products/{productId}/assets/bulk`

Create multiple assets at once with a naming pattern.

**Auth:** `owner` or `staff` | **Plan limit:** `assets`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `count` | integer | Yes | min:1, max:50 |
| `name_prefix` | string | Yes | max:150 |
| `condition` | string | No | in: excellent, good, fair, poor, damaged |
| `purchase_price` | integer | No | min:0 (cents) |
| `purchase_date` | date | No | |
| `supplier` | string | No | max:200 |
| `location` | string | No | max:200 |

**Example Request:**

```json
{
  "count": 5,
  "name_prefix": "DeWalt CS Unit",
  "condition": "excellent",
  "purchase_price": 35000,
  "location": "Warehouse A"
}
```

Creates: "DeWalt CS Unit #1", "DeWalt CS Unit #2", ... "DeWalt CS Unit #5"

**Response (201):**

```json
{
  "success": true,
  "data": {
    "assets": [ "...array of AssetResource..." ],
    "count": 5
  }
}
```

---

### Individual Asset Operations

#### `GET /v1/assets/{assetId}`

**Auth:** `owner` or `staff`

**Response (200):** Full `AssetResource` with maintenance records loaded.

---

#### `PATCH /v1/assets/{assetId}`

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | No | max:200 |
| `serial_number` | string | No | max:100, unique (ignoring self) |
| `barcode` | string | No | max:100, unique (ignoring self) |
| `qr_code` | string | No | max:100, unique (ignoring self) |
| `purchase_price` | integer | No | min:0 |
| `purchase_date` | date | No | |
| `supplier` | string | No | max:200 |
| `warranty_expires_at` | date | No | |
| `condition_notes` | string | No | max:1000 |
| `internal_notes` | string | No | max:2000 |
| `location` | string | No | max:200 |
| `is_active` | boolean | No | |

**Response (200):** Updated `AssetResource`.

---

#### `DELETE /v1/assets/{assetId}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Asset deleted", "data": null }`

---

#### `POST /v1/assets/{assetId}/status`

Transition asset to a new status.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `status` | string | Yes | in: available, reserved, rented, under_maintenance, damaged, retired, lost |
| `reason` | string | No | max:500 |

> **Frontend:** Only show transitions from `allowed_transitions` in the asset response. Invalid transitions will return 422.

**Response (200):** Updated `AssetResource` with message "Asset status updated".

---

#### `POST /v1/assets/{assetId}/condition`

Update the physical condition of an asset.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `condition` | string | Yes | in: excellent, good, fair, poor, damaged |
| `notes` | string | No | max:1000 |

**Response (200):** Updated `AssetResource` with message "Asset condition updated".

---

#### `GET /v1/assets/{assetId}/activity`

Get the audit trail / activity log for an asset.

**Auth:** `owner` or `staff`

**Query Parameters:** `per_page` (default 20, max 100)

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "event": "status_changed",
        "description": "Status changed from available to rented",
        "properties": { "old_status": "available", "new_status": "rented" },
        "user": { "id": 1, "name": "John Doe" },
        "created_at": "2026-03-25T14:30:00.000000Z"
      }
    ],
    "meta": { "current_page": 1, "last_page": 2, "per_page": 20, "total": 30 }
  }
}
```

---

### Maintenance

#### `GET /v1/assets/maintenance/upcoming`

Get upcoming maintenance across all assets.

**Auth:** `owner` or `staff`

**Query Parameters:**

| Param | Type | Default | Validation |
|-------|------|---------|------------|
| `days` | integer | 7 | max:30 |

**Response (200):** Array of `AssetMaintenanceResource`.

---

#### `GET /v1/assets/{assetId}/maintenance`

List maintenance records for a specific asset.

**Auth:** `owner` or `staff`

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `status` | string | Filter by status |
| `type` | string | Filter by type |
| `upcoming` | boolean | Only upcoming |
| `overdue` | boolean | Only overdue |
| `per_page` | integer | Items per page |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "type": "routine",
        "title": "Monthly blade inspection",
        "description": "Check blade condition and alignment",
        "work_performed": null,
        "scheduled_date": "2026-04-01",
        "completed_date": null,
        "cost": null,
        "performed_by": "Maintenance Team",
        "status": "scheduled",
        "is_overdue": false,
        "notes": null,
        "created_at": "2026-03-20T10:00:00.000000Z"
      }
    ],
    "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 3 }
  }
}
```

---

#### `POST /v1/assets/{assetId}/maintenance`

Schedule a new maintenance record.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `type` | string | Yes | in: routine, repair, inspection, cleaning, calibration |
| `title` | string | Yes | max:200 |
| `description` | string | No | max:2000 |
| `scheduled_date` | date | Yes | YYYY-MM-DD |
| `performed_by` | string | No | max:200 |
| `cost` | integer | No | min:0 (cents) |

**Response (201):** `AssetMaintenanceResource`.

---

#### `PATCH /v1/assets/{assetId}/maintenance/{maintenanceId}`

**Auth:** `owner` or `staff`

**Request Body:** Same fields as create + `notes` (max:2000), all optional.

**Response (200):** Updated `AssetMaintenanceResource`.

---

#### `POST /v1/assets/{assetId}/maintenance/{maintenanceId}/complete`

Mark maintenance as completed.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `work_performed` | string | Yes | max:2000 |
| `cost` | integer | No | min:0 (cents) |
| `completed_date` | date | No | defaults to today |

**Response (200):** `AssetMaintenanceResource` with message "Maintenance completed".

---

#### `POST /v1/assets/{assetId}/maintenance/{maintenanceId}/cancel`

**Auth:** `owner` or `staff` | **Body:** None

**Response (200):** `AssetMaintenanceResource` with message "Maintenance cancelled".

---

#### `DELETE /v1/assets/{assetId}/maintenance/{maintenanceId}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Maintenance record deleted", "data": null }`

---

## M5: Availability

> **Public tenant routes.** Require `X-Tenant-ID` but **no auth** for check/calendar endpoints. Rate limited: **60 req/min**. Lock endpoints require auth.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Product Detail (date picker) | `POST /availability/check`, `GET /availability/calendar/{productId}` |
| Cart / Multi-product check | `POST /availability/check-multiple` |
| Checkout (hold item) | `POST /availability/lock` |

---

#### `POST /v1/availability/check`

Check if a product is available for a date range.

**Auth:** None required (public)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `product_id` | integer | Yes | must exist in products |
| `start_date` | date | Yes | today or later |
| `end_date` | date | Yes | must be after start_date |
| `quantity` | integer | No | min:1, max:10, default:1 |

> **Validation:** Rental period must be 1-365 days.

**Example Request:**

```json
{
  "product_id": 1,
  "start_date": "2026-04-01",
  "end_date": "2026-04-05",
  "quantity": 1
}
```

**Response (200):** Availability result object.

**Frontend:** Use on product detail page when user selects dates in the date picker. Show a green/red availability indicator.

---

#### `POST /v1/availability/check-multiple`

Check availability for multiple products at once (cart scenario).

**Auth:** None required (public)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `items` | array | Yes | min:1, max:20 |
| `items[].product_id` | integer | Yes | must exist |
| `items[].start_date` | date | Yes | today or later |
| `items[].end_date` | date | Yes | after start_date |
| `items[].quantity` | integer | No | min:1, max:10 |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "results": [ "...availability result per item..." ]
  }
}
```

---

#### `GET /v1/availability/calendar/{productId}`

Get a monthly availability calendar for a product.

**Auth:** None required (public)

**Query Parameters:**

| Param | Type | Required | Validation |
|-------|------|----------|------------|
| `year` | integer | Yes | 2024-2030 |
| `month` | integer | Yes | 1-12 |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "product_id": 1,
    "year": 2026,
    "month": 4,
    "days": [ "...array of day availability info..." ]
  }
}
```

**Frontend:** Render a calendar view. Color-code days based on availability. Useful for date range pickers on product detail pages.

---

#### `POST /v1/availability/lock`

Acquire a temporary lock on an asset for 15 minutes (prevents double-booking during checkout).

**Auth:** Required (Sanctum)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `product_id` | integer | Yes | must exist |
| `asset_id` | integer | Yes | must exist, must belong to product |
| `start_date` | date | Yes | today or later |
| `end_date` | date | Yes | after start_date |

**Response (201):**

```json
{
  "success": true,
  "message": "Asset locked for 15 minutes",
  "data": {
    "lock_key": "lock_abc123...",
    "expires_at": "2026-03-29T10:15:00.000000Z"
  }
}
```

> **Frontend:** Store the `lock_key`. Pass it to the booking creation endpoint. Show a countdown timer (15 min). Call `/lock/extend` before expiry if user is still on checkout.

---

#### `POST /v1/availability/lock/release`

Release a previously acquired lock.

**Auth:** Required

**Request Body:**

| Field | Type | Required |
|-------|------|----------|
| `lock_key` | string | Yes |

**Response (200):** `{ "success": true, "message": "Lock released", "data": null }`

---

#### `POST /v1/availability/lock/extend`

Extend a lock by another 15 minutes.

**Auth:** Required

**Request Body:**

| Field | Type | Required |
|-------|------|----------|
| `lock_key` | string | Yes |

**Response (200):** `{ "success": true, "message": "Lock extended by 15 minutes", "data": null }`

**Error (404):** `{ "success": false, "message": "Lock not found or already expired" }`

---

## M6: Bookings

> **Tenant routes.** Authenticated users. Customers can create/view their own bookings. Owners/staff can manage all bookings.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Customer: Create Booking | `POST /bookings` |
| Customer: My Bookings | `GET /bookings` (filtered to own) |
| Customer: Booking Detail | `GET /bookings/{id}` |
| Customer: Extend Booking | `POST /bookings/{id}/extend` |
| Customer: Cancel Booking | `POST /bookings/{id}/cancel` |
| Admin: All Bookings | `GET /bookings` (with filters) |
| Admin: Booking Detail | `GET /bookings/{id}` |
| Admin: Confirm/Pickup/Return | `POST /bookings/{id}/confirm`, `/picked-up`, `/returned` |

### Booking Status Flow

```
pending -> confirmed -> active (picked-up) -> returned -> closed
    |          |            |
    v          v            v
 cancelled  cancelled    overdue (automatic)
```

---

#### `GET /v1/bookings`

List bookings with filtering. Customers see only their own; owners/staff see all.

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `status` | string | Filter by status: pending, confirmed, active, returned, cancelled, overdue |
| `product_id` | integer | Filter by product |
| `customer_email` | string | Filter by customer email (owner/staff) |
| `date_from` | date | Start date range |
| `date_to` | date | End date range (must be >= date_from) |
| `overdue` | boolean | Only overdue bookings |
| `per_page` | integer | min:1, max:100 |
| `page` | integer | min:1 |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "booking_number": "BK-20260329-001",
        "status": {
          "value": "confirmed",
          "label": "Confirmed",
          "is_active": false,
          "is_final": false,
          "allowed_transitions": ["active", "cancelled"]
        },
        "product": {
          "id": 1,
          "name": "DeWalt Circular Saw",
          "slug": "dewalt-circular-saw",
          "primary_image": "https://s3.../image.jpg"
        },
        "asset": {
          "id": 1,
          "name": "DeWalt CS Unit #1",
          "serial_number": "DW-2024-001"
        },
        "customer": {
          "name": "Jane Customer",
          "email": "jane@example.com",
          "phone": "+1234567890"
        },
        "dates": {
          "start_date": "2026-04-01",
          "end_date": "2026-04-05",
          "original_end_date": null,
          "rental_days": 4,
          "extension_count": 0,
          "is_overdue": false
        },
        "pricing": {
          "price_per_day": 2500,
          "subtotal": 10000,
          "security_deposit": 10000,
          "delivery_fee": 0,
          "discount_amount": 0,
          "total_amount": 20000,
          "currency": "USD"
        },
        "delivery": {
          "type": "pickup",
          "address": null
        },
        "timeline": {
          "created_at": "2026-03-28T10:00:00.000000Z",
          "confirmed_at": "2026-03-28T12:00:00.000000Z",
          "picked_up_at": null,
          "returned_at": null
        },
        "notes": "Please include extra blade"
      }
    ],
    "meta": { "current_page": 1, "last_page": 5, "per_page": 20, "total": 87 }
  }
}
```

> **Frontend Notes:**
> - Use `status.allowed_transitions` to render action buttons (Confirm, Mark Picked Up, etc.)
> - Use `dates.is_overdue` to highlight overdue bookings in red
> - All prices in **cents**. `total_amount = subtotal + security_deposit + delivery_fee - discount_amount`

---

#### `POST /v1/bookings`

Create a new booking.

**Auth:** Any authenticated user (typically a customer)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `product_id` | integer | Yes | must exist |
| `start_date` | date | Yes | today or later |
| `end_date` | date | Yes | after start_date |
| `asset_id` | integer | No | must exist, specific unit preference |
| `delivery_type` | string | No | in: pickup, delivery |
| `delivery_address` | string | No | required if delivery_type=delivery, max:500 |
| `delivery_zone_id` | integer | No | must exist in delivery_zones |
| `distance_km` | numeric | No | min:0 |
| `coupon_code` | string | No | max:50 |
| `notes` | string | No | max:1000 |
| `lock_key` | string | No | from availability lock |

**Example Request:**

```json
{
  "product_id": 1,
  "start_date": "2026-04-01",
  "end_date": "2026-04-05",
  "delivery_type": "pickup",
  "coupon_code": "SPRING20",
  "notes": "Please include extra blade",
  "lock_key": "lock_abc123..."
}
```

**Response (201):** `BookingResource` (full shape as above).

**Frontend Flow:**
1. User selects product + dates on product detail page
2. Check availability via `POST /availability/check`
3. Optionally acquire lock via `POST /availability/lock`
4. Submit booking with `lock_key`
5. Redirect to booking detail / payment page

---

#### `GET /v1/bookings/{id}`

**Auth:** Any authenticated user (customers see own only)

**Response (200):** Full `BookingResource` with all nested data.

---

#### `POST /v1/bookings/{id}/confirm`

Confirm a pending booking. Typically done by owner/staff after reviewing.

**Auth:** `owner` or `staff`

**Request Body:** None

**Response (200):**

```json
{
  "success": true,
  "message": "Booking confirmed",
  "data": { "...BookingResource with status.value: 'confirmed'..." }
}
```

---

#### `POST /v1/bookings/{id}/picked-up`

Mark booking as picked up (customer has the item). Transitions `confirmed` -> `active`.

**Auth:** `owner` or `staff`

**Request Body:** None

**Response (200):** `BookingResource` with `status.value: "active"`.

---

#### `POST /v1/bookings/{id}/extend`

Extend the rental period.

**Auth:** Any authenticated user (booking owner or staff)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `additional_days` | integer | Yes | min:1, max:30 |

**Response (200):** `BookingResource` with updated `dates.end_date` and incremented `dates.extension_count`.

> **Frontend:** Show the new end date and any additional charges. The `dates.original_end_date` preserves the first end date.

---

#### `POST /v1/bookings/{id}/cancel`

Cancel a booking.

**Auth:** Any authenticated user (booking owner or staff)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `reason` | string | Yes | min:10, max:500 |

**Response (200):** `BookingResource` with `status.value: "cancelled"` and `cancellation` block populated:

```json
{
  "cancellation": {
    "cancelled_at": "2026-03-29T15:00:00.000000Z",
    "cancelled_by": 5,
    "reason": "Plans changed, no longer need the equipment"
  }
}
```

---

#### `POST /v1/bookings/{id}/returned`

Mark item as returned by customer. Transitions `active` -> `returned`.

**Auth:** `owner` or `staff`

**Request Body:** None

**Response (200):** `BookingResource` with `status.value: "returned"`.

> **Frontend:** After marking returned, show the inspection form (M8) to record item condition.

---

#### `POST /v1/bookings/{id}/review`

Submit a review for a completed booking. See [M18: Reviews](#m18-reviews).

---

## M7: Payments

> **Tenant routes.** Payment processing via Stripe. All amounts in **cents**.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Checkout / Payment | `POST /bookings/{id}/payments/initiate` |
| Booking Detail (payment history) | `GET /bookings/{id}/payments` |
| Admin: Refund | `POST /bookings/{id}/payments/refund` |
| Admin: Deposit Management | `POST .../deposit/capture`, `POST .../deposit/release` |

---

#### `GET /v1/bookings/{bookingId}/payments`

List all payments for a booking.

**Auth:** Any authenticated user (customers see own)

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "booking_id": 1,
      "type": {
        "value": "rental_charge",
        "label": "Rental Charge"
      },
      "status": "captured",
      "amount": 10000,
      "amount_captured": 10000,
      "amount_refunded": 0,
      "refundable_amount": 10000,
      "currency": "USD",
      "client_secret": null,
      "stripe_payment_intent_id": "pi_abc123",
      "description": "Rental charge for BK-20260329-001",
      "failure_reason": null,
      "is_refundable": true,
      "paid_at": "2026-03-28T12:30:00.000000Z",
      "refunded_at": null,
      "created_at": "2026-03-28T12:00:00.000000Z"
    }
  ]
}
```

> **Frontend Notes:**
> - `client_secret` is only present for `pending`/`processing` payments. Use it with Stripe.js to confirm payment on the frontend.
> - `is_refundable` and `refundable_amount` determine if the refund button should be shown.

---

#### `POST /v1/bookings/{bookingId}/payments/initiate`

Initiate a payment (creates a Stripe PaymentIntent).

**Auth:** Any authenticated user

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `type` | string | Yes | in: `rental_charge`, `security_deposit` |

**Response (201):**

```json
{
  "success": true,
  "message": "Payment initiated",
  "data": {
    "id": 2,
    "type": { "value": "security_deposit", "label": "Security Deposit" },
    "status": "pending",
    "amount": 10000,
    "client_secret": "pi_abc123_secret_xyz",
    "stripe_payment_intent_id": "pi_abc123",
    "...rest of PaymentResource..."
  }
}
```

**Frontend: Stripe Integration**

```javascript
import { loadStripe } from '@stripe/stripe-js';

const stripe = await loadStripe('pk_test_...');

// 1. Initiate payment
const { data: payment } = await axios.post(
  `/v1/bookings/${bookingId}/payments/initiate`,
  { type: 'rental_charge' }
);

// 2. Confirm with Stripe.js using client_secret
const { error } = await stripe.confirmCardPayment(payment.client_secret, {
  payment_method: {
    card: cardElement,  // from Stripe Elements
  }
});

if (error) {
  // Show error to user
} else {
  // Payment successful - refresh booking details
}
```

---

#### `POST /v1/bookings/{bookingId}/payments/refund`

Process a full or partial refund.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `payment_id` | integer | Yes | must exist in payments |
| `amount` | integer | No | min:1 (cents). Omit for full refund |
| `reason` | string | No | max:500 |

**Response (200):** `PaymentResource` with message "Refund processed".

---

#### `POST /v1/bookings/{bookingId}/payments/deposit/capture`

Capture (charge) the security deposit (e.g., after damage found during inspection).

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `amount` | integer | No | min:1 (cents). Omit to capture full deposit |

**Response (200):** `PaymentResource` with message "Deposit captured".

---

#### `POST /v1/bookings/{bookingId}/payments/deposit/release`

Release (void) the security deposit back to the customer.

**Auth:** `owner` or `staff`

**Request Body:** None

**Response (200):** `PaymentResource` with message "Deposit released".

> **Frontend:** After inspection, show two buttons: "Release Deposit" (clean return) or "Capture Deposit" (damage found). Partial capture can be done by specifying an amount.

---

## M8: Inspections

> **Tenant routes.** Post-return inspection to document item condition and decide on deposit.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Admin: Inspection Form | `POST /bookings/{bookingId}/inspect` |
| Admin: View Inspection | `GET /bookings/{bookingId}/inspection` |
| Admin: Close Booking | `POST /bookings/{bookingId}/close` |
| Admin: Overdue Info | `GET /bookings/{bookingId}/overdue-info` |

---

#### `POST /v1/bookings/{bookingId}/inspect`

Record a return inspection for a booking.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `condition_after` | string | Yes | in: excellent, good, fair, poor, damaged |
| `outcome` | string | Yes | in: clean_return, minor_damage, major_damage, total_loss |
| `notes` | string | No | max:2000 |
| `damage_items` | array | No | required if outcome != clean_return |
| `damage_items[].description` | string | Yes* | max:500 |
| `damage_items[].estimated_cost` | integer | Yes* | min:0 (cents) |
| `damage_items[].photo_path` | string | No | max:500 |
| `deposit_decision` | string | No | in: release, partial_capture, full_capture |
| `deposit_capture_amount` | integer | No | required if deposit_decision=partial_capture |
| `maintenance_required` | boolean | No | |
| `maintenance_notes` | string | No | max:1000 |

> **Validation Rules:**
> - If `outcome` = `clean_return`, `damage_items` must be empty
> - If `deposit_decision` = `partial_capture`, `deposit_capture_amount` is required

**Example Request (damage found):**

```json
{
  "condition_after": "fair",
  "outcome": "minor_damage",
  "notes": "Blade guard has a crack",
  "damage_items": [
    {
      "description": "Cracked blade guard",
      "estimated_cost": 5000,
      "photo_path": "inspections/photo1.jpg"
    }
  ],
  "deposit_decision": "partial_capture",
  "deposit_capture_amount": 5000,
  "maintenance_required": true,
  "maintenance_notes": "Replace blade guard before next rental"
}
```

**Response (201):**

```json
{
  "success": true,
  "message": "Inspection recorded",
  "data": {
    "id": 1,
    "booking_id": 1,
    "asset_id": 1,
    "condition_before": "excellent",
    "condition_after": "fair",
    "outcome": {
      "value": "minor_damage",
      "label": "Minor Damage",
      "requires_maintenance": true,
      "suggested_deposit_decision": "partial_capture"
    },
    "damage_items": [
      {
        "description": "Cracked blade guard",
        "estimated_cost": 5000,
        "photo_path": "inspections/photo1.jpg"
      }
    ],
    "total_damage_cost": 5000,
    "deposit_decision": "partial_capture",
    "deposit_capture_amount": 5000,
    "maintenance_required": true,
    "maintenance_notes": "Replace blade guard before next rental",
    "inspector": { "id": 1, "name": "John Doe" },
    "inspected_at": "2026-04-06T10:00:00.000000Z",
    "created_at": "2026-04-06T10:00:00.000000Z"
  }
}
```

**Frontend Flow:**
1. When booking is `returned`, show inspection form
2. Form fields: condition dropdown, outcome dropdown, damage item list (dynamic add/remove), deposit decision
3. Use `outcome.suggested_deposit_decision` as a pre-selected default
4. After submitting, call deposit capture/release endpoint as appropriate
5. Finally call `/close` to finalize the booking

---

#### `GET /v1/bookings/{bookingId}/inspection`

**Auth:** `owner` or `staff`

**Response (200):** `ReturnInspectionResource` (same shape as above).

---

#### `POST /v1/bookings/{bookingId}/close`

Close the booking after inspection and deposit handling are complete. Transitions `returned` -> `closed`.

**Auth:** `owner` or `staff`

**Request Body:** None

**Response (200):** `BookingResource` with message "Booking closed".

---

#### `GET /v1/bookings/{bookingId}/overdue-info`

Get overdue details for a booking.

**Auth:** `owner` or `staff`

**Response (200):**

```json
{
  "success": true,
  "data": {
    "booking_id": 1,
    "is_overdue": true,
    "overdue_days": 3,
    "overdue_fee": 7500,
    "end_date": "2026-04-05"
  }
}
```

---

## M9: Notifications

> **Tenant routes.** View notification logs (emails/SMS sent for a booking).

#### `GET /v1/bookings/{bookingId}/notifications`

**Auth:** `owner` or `staff`

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "channel": "email",
      "type": "booking_confirmed",
      "recipient": "jane@example.com",
      "subject": "Your booking BK-20260329-001 is confirmed",
      "status": "sent",
      "sent_at": "2026-03-28T12:01:00.000000Z",
      "error": null,
      "created_at": "2026-03-28T12:00:00.000000Z"
    }
  ]
}
```

**Frontend:** Display as a timeline on the booking detail page showing all communications sent.

---

## M10: Profile & Staff Management

> **Tenant routes.** Profile is accessible to any authenticated user. Staff management is owner-only.

### Profile

#### `GET /v1/profile`

**Auth:** Any authenticated user

**Response (200):**

```json
{
  "success": true,
  "data": {
    "id": 1,
    "name": "John Doe",
    "email": "john@example.com",
    "phone": "+1234567890",
    "role": "owner",
    "is_active": true,
    "permissions": ["manage_staff", "manage_settings", "view_reports"],
    "created_at": "2026-01-15T08:00:00.000000Z"
  }
}
```

---

#### `PATCH /v1/profile`

**Auth:** Any authenticated user

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | No | max:255 |
| `email` | string | No | valid email, max:255 |
| `phone` | string | No | max:20 |

**Response (200):** Updated `ProfileResource` with message "Profile updated".

---

#### `PATCH /v1/profile/password`

**Auth:** Any authenticated user

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `current_password` | string | Yes | must match current |
| `password` | string | Yes | min:8 |
| `password_confirmation` | string | Yes | must match password |

**Response (200):**

```json
{
  "success": true,
  "message": "Password changed",
  "data": {
    "token": "4|newtoken..."
  }
}
```

> **Frontend:** After password change, all existing tokens are revoked. A new token is returned. Update stored token immediately.

---

### Staff Management

#### `GET /v1/staff`

**Auth:** `owner` only

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `search` | string | Search name/email |
| `is_active` | boolean | Active filter |
| `per_page` | integer | |
| `page` | integer | |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 2,
        "name": "Staff Member",
        "email": "staff@example.com",
        "phone": null,
        "role": "staff",
        "is_active": true,
        "permissions": ["manage_products", "manage_bookings"],
        "custom_permissions": ["manage_products", "manage_bookings"],
        "created_at": "2026-02-10T10:00:00.000000Z"
      }
    ],
    "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 3 }
  }
}
```

---

#### `POST /v1/staff`

Invite a new staff member via email.

**Auth:** `owner` only | **Plan limit:** `staff_users`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | valid email, max:255 |
| `name` | string | No | max:255 |
| `permissions` | array | No | array of permission strings |
| `permissions[]` | string | Yes* | valid permission name |

**Valid Permissions:** `manage_staff`, `manage_settings`, `manage_products`, `manage_bookings`, `manage_assets`, `view_reports`, `manage_customers`, `manage_deliveries`

**Response (201):**

```json
{
  "success": true,
  "message": "Invitation sent",
  "data": {
    "id": 1,
    "email": "newstaff@example.com",
    "name": "New Staff",
    "permissions": ["manage_products", "manage_bookings"],
    "invited_by": 1,
    "expires_at": "2026-04-05T10:00:00.000000Z",
    "accepted_at": null,
    "created_at": "2026-03-29T10:00:00.000000Z"
  }
}
```

---

#### `POST /v1/staff/accept-invite`

Accept a staff invitation and create account. **Public route** (no auth needed, rate limited).

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | valid email |
| `token` | string | Yes | from invitation email |
| `name` | string | Yes | max:255 |
| `password` | string | Yes | min:8 |
| `password_confirmation` | string | Yes | must match |

**Response (201):**

```json
{
  "success": true,
  "message": "Invitation accepted",
  "data": {
    "user": { "...StaffResource..." },
    "token": "5|stafftoken..."
  }
}
```

---

#### `GET /v1/staff/{id}`

**Auth:** `owner` only

**Response (200):** `StaffResource`.

---

#### `PATCH /v1/staff/{id}`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | No | max:255 |
| `email` | string | No | valid email, max:255 |
| `phone` | string | No | max:20 |
| `is_active` | boolean | No | deactivate/reactivate |

**Response (200):** Updated `StaffResource` with message "Staff updated".

---

#### `DELETE /v1/staff/{id}`

Deactivate a staff member (soft delete).

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Staff deactivated", "data": null }`

---

#### `POST /v1/staff/{id}/permissions`

Update a staff member's permissions.

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `permissions` | array | Yes | min:1 item |
| `permissions[]` | string | Yes | valid permission name |

**Response (200):** `StaffResource` with message "Permissions updated".

---

## M11: Delivery & Zones

> **Tenant routes.** Delivery scheduling and zone management.

### Delivery Zones (Settings)

Managed under settings. See also [M14: Settings](#m14-tenant-settings).

#### `GET /v1/settings/delivery-zones`

**Auth:** `owner` only

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "name": "Downtown",
      "fee_type": "flat",
      "fee_amount": 1500,
      "free_above_threshold": null,
      "description": "Central downtown area",
      "is_active": true,
      "created_at": "2026-01-20T10:00:00.000000Z"
    }
  ]
}
```

---

#### `POST /v1/settings/delivery-zones`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | max:255 |
| `fee_type` | string | Yes | in: flat, per_km, free_above |
| `fee_amount` | integer | Yes | min:0 (cents) |
| `free_above_threshold` | integer | No | required if fee_type=free_above, min:0 |
| `description` | string | No | max:1000 |
| `is_active` | boolean | No | |

**Fee Type Behaviors:**

| Type | Behavior |
|------|----------|
| `flat` | Fixed fee regardless of distance |
| `per_km` | `fee_amount` * distance_km |
| `free_above` | Free if order exceeds `free_above_threshold`, else `fee_amount` |

**Response (201):** `DeliveryZoneResource`.

---

#### `PATCH /v1/settings/delivery-zones/{id}`

**Auth:** `owner` only | **Body:** Same fields, all optional.

---

#### `DELETE /v1/settings/delivery-zones/{id}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Delivery zone deleted", "data": null }`

---

### Deliveries

#### `POST /v1/bookings/{bookingId}/delivery`

Schedule a delivery for a booking.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `scheduled_date` | date | Yes | today or later |
| `address` | string | No | max:500 |
| `delivery_zone_id` | integer | No | must exist |
| `distance_km` | numeric | No | min:0 |
| `notes` | string | No | max:1000 |

**Response (201):**

```json
{
  "success": true,
  "message": "Delivery scheduled",
  "data": {
    "id": 1,
    "booking_id": 1,
    "status": { "value": "scheduled", "label": "Scheduled" },
    "scheduled_date": "2026-04-01",
    "address": "123 Main St",
    "notes": null,
    "fee": 1500,
    "distance_km": 5.2,
    "zone": { "id": 1, "name": "Downtown", "...DeliveryZoneResource..." },
    "driver": null,
    "booking": {
      "id": 1,
      "booking_number": "BK-20260329-001",
      "product_name": "DeWalt Circular Saw",
      "customer_name": "Jane Customer"
    },
    "dispatched_at": null,
    "delivered_at": null,
    "created_at": "2026-03-29T10:00:00.000000Z"
  }
}
```

---

#### `GET /v1/deliveries`

List all deliveries with filtering.

**Auth:** `owner` or `staff`

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `status` | string | scheduled, dispatched, delivered, cancelled |
| `assigned_to` | integer | Driver user ID |
| `date_from` | date | |
| `date_to` | date | |
| `per_page` | integer | |

**Response (200):** Paginated `DeliveryResource` collection.

---

#### `PATCH /v1/deliveries/{id}/status`

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `status` | string | Yes | in: scheduled, dispatched, delivered, cancelled |

**Response (200):** Updated `DeliveryResource`.

---

#### `POST /v1/deliveries/{id}/assign`

Assign a staff member as delivery driver.

**Auth:** `owner` or `staff`

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `staff_id` | integer | Yes | must exist in users |

**Response (200):** `DeliveryResource` with message "Driver assigned".

---

## M12: Reports

> **Tenant routes.** Analytics and reporting endpoints. All require `owner` or `staff` role.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Dashboard: Revenue Chart | `GET /reports/revenue` |
| Dashboard: Booking Stats | `GET /reports/bookings` |
| Reports: Inventory | `GET /reports/inventory` |
| Reports: Asset Performance | `GET /reports/assets` |
| Reports: Customer Insights | `GET /reports/customers` |
| Reports: Overdue List | `GET /reports/overdue` |

> **Note:** All report endpoints (except overdue) accept the same query parameters for date range filtering.

### Common Query Parameters

| Param | Type | Required | Validation |
|-------|------|----------|------------|
| `date_from` | date | Yes | YYYY-MM-DD |
| `date_to` | date | Yes | must be >= date_from |
| `period` | string | No | in: daily, weekly, monthly (default: daily) |

---

#### `GET /v1/reports/revenue`

Revenue analytics over a date range.

**Auth:** `owner` or `staff`

**Response (200):** Revenue report data (totals, breakdowns by period).

---

#### `GET /v1/reports/bookings`

Booking statistics and trends.

**Auth:** `owner` or `staff`

**Response (200):** Booking analytics (counts by status, trends over time).

---

#### `GET /v1/reports/inventory`

Inventory utilization report.

**Auth:** `owner` or `staff`

**Response (200):** Utilization rates, idle inventory, top-performing products.

---

#### `GET /v1/reports/assets`

Asset performance and health report.

**Auth:** `owner` or `staff`

**Response (200):** Asset condition distribution, maintenance costs, depreciation.

---

#### `GET /v1/reports/customers`

Customer insights and segmentation.

**Auth:** `owner` or `staff`

**Response (200):** Top customers, repeat rates, lifetime value.

---

#### `GET /v1/reports/overdue`

List of currently overdue bookings with fees.

**Auth:** `owner` or `staff`

**Query Parameters:** None (real-time snapshot)

**Response (200):** Overdue bookings with calculated fees.

**Frontend:** Display as a dashboard widget with red alerts. Link each item to the booking detail page.

---

## M13: Billing & Subscriptions

> **Tenant routes.** Subscription management via Stripe. **Owner only.**

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Settings: Current Plan | `GET /billing/plan` |
| Settings: Change Plan | `GET /billing/plans`, `POST /billing/subscribe` |
| Settings: Cancel Plan | `POST /billing/cancel` |
| Settings: Invoices | `GET /billing/invoices` |

---

#### `GET /v1/billing/plan`

Get the current subscription plan details.

**Auth:** `owner` only

**Response (200):** Current plan object with limits and usage.

---

#### `GET /v1/billing/plans`

List all available subscription plans.

**Auth:** `owner` only

**Response (200):** Array of available plans with features and pricing.

**Frontend:** Render as a pricing table with current plan highlighted. Show upgrade/downgrade buttons.

---

#### `POST /v1/billing/subscribe`

Create or change subscription.

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `plan_id` | integer | Yes | must exist in plans |
| `billing_cycle` | string | Yes | in: monthly, yearly |
| `payment_method_id` | string | No | Stripe payment method ID |

**Response (200):** Subscription object with message "Subscription created".

---

#### `POST /v1/billing/cancel`

Cancel the current subscription.

**Auth:** `owner` only

**Request Body:** None

**Response (200):** Cancellation result.

---

#### `GET /v1/billing/invoices`

List billing invoices.

**Auth:** `owner` only

**Response (200):** Array of invoice objects.

---

## M14: Tenant Settings

> **Tenant routes.** All settings endpoints require `owner` role. Settings are organized by section.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Settings: Store Info | `GET/PATCH /settings/store` |
| Settings: Rental Policies | `GET/PATCH /settings/rental-policies` |
| Settings: Business Hours | `GET/PATCH /settings/business-hours` |
| Settings: Holidays | `GET/POST/DELETE /settings/holidays` |
| Settings: Notifications | `GET/PATCH /settings/notifications` |
| Settings: Payment | `GET/PATCH /settings/payment` |
| Settings: Booking | `GET/PATCH /settings/booking` |

---

### Store Settings

#### `GET /v1/settings/store`

**Auth:** `owner` only

**Response (200):** Store configuration object.

---

#### `PATCH /v1/settings/store`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | No | max:255 |
| `email` | string | No | valid email, max:255 |
| `phone` | string | No | max:20 |
| `logo` | string | No | max:500 (URL) |
| `address` | string | No | max:500 |
| `city` | string | No | max:100 |
| `state` | string | No | max:100 |
| `zip` | string | No | max:20 |
| `country` | string | No | max:100 |
| `currency` | string | No | exactly 3 chars (e.g. USD) |
| `timezone` | string | No | max:50 |

**Response (200):** Updated store object with message "Store settings updated".

---

### Rental Policies

#### `GET /v1/settings/rental-policies`

**Auth:** `owner` only

**Response (200):** Rental policies object.

---

#### `PATCH /v1/settings/rental-policies`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `deposit_policy` | string | No | max:5000 |
| `late_return_policy` | string | No | max:5000 |
| `damage_policy` | string | No | max:5000 |

**Response (200):** Updated policies with message "Rental policies updated".

**Frontend:** Use a rich text editor for these policy fields. Display them on the customer-facing booking page.

---

### Business Hours

#### `GET /v1/settings/business-hours`

**Auth:** `owner` only

**Response (200):** Business hours object.

---

#### `PATCH /v1/settings/business-hours`

**Auth:** `owner` only

**Request Body:**

```json
{
  "hours": {
    "monday":    { "open": "09:00", "close": "17:00", "is_open": true },
    "tuesday":   { "open": "09:00", "close": "17:00", "is_open": true },
    "wednesday": { "open": "09:00", "close": "17:00", "is_open": true },
    "thursday":  { "open": "09:00", "close": "17:00", "is_open": true },
    "friday":    { "open": "09:00", "close": "17:00", "is_open": true },
    "saturday":  { "open": "10:00", "close": "14:00", "is_open": true },
    "sunday":    { "open": null, "close": null, "is_open": false }
  }
}
```

**Validation:** Time format: `H:i` (24-hour).

**Response (200):** Updated hours with message "Business hours updated".

---

### Holidays

#### `GET /v1/settings/holidays`

**Auth:** `owner` only

**Response (200):** Array of holiday objects.

---

#### `POST /v1/settings/holidays`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `date` | date | Yes | today or later |
| `name` | string | No | max:255 |

**Response (201):**

```json
{
  "success": true,
  "message": "Holiday added",
  "data": { "id": 1, "date": "2026-12-25", "name": "Christmas Day" }
}
```

---

#### `DELETE /v1/settings/holidays/{id}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Holiday removed", "data": null }`

---

### Notification Settings

#### `GET /v1/settings/notifications`

**Auth:** `owner` only

**Response (200):** Notification settings object.

---

#### `PATCH /v1/settings/notifications`

**Auth:** `owner` only

**Request Body:**

```json
{
  "notifications": {
    "booking_created":   { "email": true, "sms": false },
    "booking_confirmed": { "email": true, "sms": true },
    "booking_cancelled": { "email": true, "sms": false },
    "booking_overdue":   { "email": true, "sms": true },
    "booking_returned":  { "email": true, "sms": false }
  }
}
```

**Response (200):** Updated settings with message "Notification settings updated".

---

### Payment Settings

#### `GET /v1/settings/payment`

**Auth:** `owner` only

**Response (200):** Payment settings object.

---

#### `PATCH /v1/settings/payment`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `accepted_methods` | array | No | items in: card, bank_transfer, cash |
| `deposit_percentage` | integer | No | 0-100 |
| `deposit_required` | boolean | No | |

**Response (200):** Updated settings with message "Payment settings updated".

---

### Booking Settings

#### `GET /v1/settings/booking`

**Auth:** `owner` only

**Response (200):** Booking settings object.

---

#### `PATCH /v1/settings/booking`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `auto_confirm` | boolean | No | auto-confirm bookings without manual review |
| `min_advance_notice_hours` | integer | No | min:0 (hours before start_date) |
| `max_advance_booking_days` | integer | No | min:1 (how far ahead customers can book) |

**Response (200):** Updated settings with message "Booking settings updated".

---

## M15: Search

> **Tenant route.** Full-text product search with category facets.

#### `GET /v1/catalog/search`

**Auth:** Any authenticated user

**Query Parameters:**

| Param | Type | Required | Validation |
|-------|------|----------|------------|
| `q` | string | Yes | minimum 2 characters |
| `per_page` | integer | No | default: 20 |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "products": {
      "data": [ "...ProductResource collection..." ],
      "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 5 }
    },
    "categories": [
      { "id": 1, "name": "Power Tools", "count": 3 },
      { "id": 2, "name": "Hand Tools", "count": 2 }
    ]
  }
}
```

**Frontend:** Implement as a search bar with debounced input (300ms). Show category facets as filter chips. Highlight matching terms in results.

```javascript
const search = debounce(async (query) => {
  if (query.length < 2) return;
  const res = await axios.get('/v1/catalog/search', { params: { q: query } });
  setProducts(res.data.products.data);
  setCategoryFacets(res.data.categories);
}, 300);
```

---

## M17: Coupons

> **Tenant routes.** Coupon management (owner) and validation (any authenticated user).

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Admin: Coupon Management | `GET/POST/PATCH/DELETE /coupons` |
| Checkout: Apply Coupon | `POST /coupons/validate` |

---

#### `POST /v1/coupons/validate`

Validate a coupon code at checkout.

**Auth:** Any authenticated user

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `code` | string | Yes | coupon code |
| `product_id` | integer | Yes | must exist |
| `subtotal` | integer | Yes | min:1 (cents) |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "code": "SPRING20",
    "type": "percentage",
    "value": 20,
    "discount_amount": 2000
  }
}
```

**Frontend:** Show "Apply Coupon" input on checkout. On success, display discount and update total.

---

#### `GET /v1/coupons`

**Auth:** `owner` only

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `search` | string | Search in code |
| `is_active` | boolean | Active filter |
| `per_page` | integer | |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [
      {
        "id": 1,
        "code": "SPRING20",
        "type": "percentage",
        "value": 20,
        "min_order_amount": 5000,
        "max_uses": 100,
        "uses_count": 15,
        "valid_from": "2026-03-01",
        "valid_until": "2026-04-30",
        "applicable_products": [1, 5, 10],
        "is_active": true,
        "is_valid": true,
        "created_at": "2026-02-28T10:00:00.000000Z"
      }
    ],
    "meta": { "current_page": 1, "last_page": 1, "per_page": 20, "total": 5 }
  }
}
```

---

#### `POST /v1/coupons`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `code` | string | Yes | max:50, unique |
| `type` | string | Yes | in: percentage, fixed |
| `value` | integer | Yes | min:1 (percentage or cents) |
| `min_order_amount` | integer | No | min:0 (cents) |
| `max_uses` | integer | No | min:1 |
| `valid_from` | date | Yes | YYYY-MM-DD |
| `valid_until` | date | Yes | must be >= valid_from |
| `applicable_products` | array | No | array of product IDs |
| `is_active` | boolean | No | |

**Response (201):** `CouponResource`.

---

#### `GET /v1/coupons/{id}`

**Auth:** `owner` only

**Response (200):** `CouponResource`.

---

#### `PATCH /v1/coupons/{id}`

**Auth:** `owner` only | **Body:** Same fields as create, all optional.

**Response (200):** Updated `CouponResource`.

---

#### `DELETE /v1/coupons/{id}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Coupon deleted", "data": null }`

---

## M18: Reviews

> **Tenant routes.** Customers submit reviews. Owners moderate and respond.

### Frontend Page Map

| Page | Endpoints Used |
|------|---------------|
| Customer: Submit Review | `POST /bookings/{id}/review` |
| Product Detail: Reviews | `GET /catalog/products/{id}/reviews` |
| Admin: Review Moderation | `GET /reviews`, `PATCH /reviews/{id}/publish` |
| Admin: Owner Response | `PATCH /reviews/{id}/respond` |

---

#### `POST /v1/bookings/{id}/review`

Submit a review for a completed booking.

**Auth:** Any authenticated user (booking customer)

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `rating` | integer | Yes | 1-5 |
| `title` | string | No | max:255 |
| `body` | string | No | max:5000 |

**Response (201):**

```json
{
  "success": true,
  "message": "Review submitted",
  "data": {
    "id": 1,
    "booking_id": 1,
    "product_id": 1,
    "rating": 5,
    "title": "Great experience!",
    "body": "The circular saw was in perfect condition...",
    "is_published": false,
    "owner_response": null,
    "owner_responded_at": null,
    "user": { "id": 5, "name": "Jane Customer" },
    "product": { "id": 1, "name": "DeWalt Circular Saw" },
    "created_at": "2026-04-10T10:00:00.000000Z"
  }
}
```

> **Note:** Reviews start as `is_published: false`. Owner must publish them.

---

#### `GET /v1/catalog/products/{id}/reviews`

Get published reviews for a product.

**Auth:** Any authenticated user

**Query Parameters:** `per_page` (default 20)

**Response (200):**

```json
{
  "success": true,
  "data": {
    "data": [ "...ReviewResource collection..." ],
    "meta": { "current_page": 1, "last_page": 2, "per_page": 20, "total": 25 },
    "stats": {
      "average_rating": 4.5,
      "total_reviews": 25,
      "rating_distribution": { "5": 15, "4": 6, "3": 2, "2": 1, "1": 1 }
    }
  }
}
```

**Frontend:** Show star rating distribution bar chart + paginated review list.

---

#### `GET /v1/reviews`

List all reviews for moderation.

**Auth:** `owner` or `staff`

**Query Parameters:**

| Param | Type | Description |
|-------|------|-------------|
| `is_published` | boolean | Published/unpublished filter |
| `product_id` | integer | Filter by product |
| `rating` | integer | Filter by rating |
| `per_page` | integer | |

**Response (200):** Paginated `ReviewResource` collection.

---

#### `PATCH /v1/reviews/{id}/respond`

Add owner response to a review.

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `response` | string | Yes | max:5000 |

**Response (200):** `ReviewResource` with `owner_response` populated and message "Response added".

---

#### `PATCH /v1/reviews/{id}/publish`

Publish or unpublish a review.

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required |
|-------|------|----------|
| `is_published` | boolean | Yes |

**Response (200):** `ReviewResource` with message "Review published" or "Review unpublished".

---

## M19: Integrations & Webhooks

> **Tenant routes.** External service integrations and outbound webhook management.

### Integrations (Stubs)

> These are placeholder endpoints for future OAuth-based integrations. Currently return 501.

#### `POST /v1/integrations/quickbooks/sync-invoice`

**Auth:** `owner` only

**Request Body:** `{ "booking_id": 1 }`

**Response (501):**

```json
{
  "success": false,
  "message": "QuickBooks integration is not yet configured. Connect your QuickBooks account in Settings > Integrations.",
  "code": "NOT_IMPLEMENTED"
}
```

---

#### `POST /v1/integrations/xero/sync-invoice`

**Auth:** `owner` only

**Request Body:** `{ "booking_id": 1 }`

**Response (501):** Same pattern as QuickBooks.

---

#### `GET /v1/integrations/shipping/rates`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `pickup_address` | string | Yes | max:500 |
| `delivery_address` | string | Yes | max:500 |

**Response (200):**

```json
{
  "success": true,
  "data": {
    "rates": [
      {
        "provider": "DHL",
        "service": "DHL Express",
        "estimated_days": null,
        "price": null,
        "currency": null,
        "available": false,
        "message": "DHL integration not configured"
      }
    ],
    "message": "Shipping integrations are placeholders. Configure API keys in Settings > Integrations."
  }
}
```

---

### Outbound Webhooks

Allow tenants to receive real-time notifications about booking events at their own URLs.

#### `GET /v1/settings/webhooks`

**Auth:** `owner` only

**Response (200):**

```json
{
  "success": true,
  "data": [
    {
      "id": 1,
      "url": "https://example.com/webhooks/rentals",
      "secret": "whsec_abc123...",
      "events": ["booking.created", "booking.confirmed", "booking.returned"],
      "description": "Main webhook endpoint",
      "is_active": true,
      "created_at": "2026-02-01T10:00:00.000000Z"
    }
  ]
}
```

---

#### `POST /v1/settings/webhooks`

**Auth:** `owner` only

**Request Body:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `url` | string | Yes | valid URL, max:500 |
| `secret` | string | No | max:255 (for signature verification) |
| `events` | array | Yes | min:1 |
| `events[]` | string | Yes | in: booking.created, booking.confirmed, booking.cancelled, booking.overdue, booking.returned, booking.extended, booking.picked_up |
| `description` | string | No | max:255 |
| `is_active` | boolean | No | |

**Response (201):** `WebhookEndpointResource`.

---

#### `GET /v1/settings/webhooks/{id}`

**Auth:** `owner` only

**Response (200):** `WebhookEndpointResource`.

---

#### `PATCH /v1/settings/webhooks/{id}`

**Auth:** `owner` only | **Body:** Same fields, all optional (except `events[]` items must be valid if array present).

**Response (200):** Updated `WebhookEndpointResource`.

---

#### `DELETE /v1/settings/webhooks/{id}`

**Auth:** `owner` only

**Response (200):** `{ "success": true, "message": "Webhook endpoint deleted", "data": null }`

---

#### `POST /v1/settings/webhooks/{id}/test`

Send a test webhook to verify the endpoint works.

**Auth:** `owner` only

**Request Body:** None

**Response (200):** Test result with message "Test webhook sent".

---

### Stripe Webhook (Inbound)

#### `POST /v1/webhooks/stripe`

> **No auth, no tenant middleware.** This is called by Stripe directly.

Handles Stripe webhook events (payment confirmations, failures, etc.). The system verifies the Stripe signature automatically.

**Frontend:** No action needed. This is server-to-server.

---

## Appendix: Quick Reference

### All Endpoints Summary

| Method | Endpoint | Auth | Role |
|--------|----------|------|------|
| **Central** | | | |
| POST | `/v1/central/register` | None | - |
| **Health** | | | |
| GET | `/v1/health` | Tenant | - |
| GET | `/v1/health/detailed` | Tenant | - |
| **Auth** | | | |
| POST | `/v1/auth/login` | Tenant | - |
| POST | `/v1/auth/logout` | Auth | Any |
| GET | `/v1/auth/me` | Auth | Any |
| POST | `/v1/auth/forgot-password` | Tenant | - |
| POST | `/v1/auth/reset-password` | Tenant | - |
| POST | `/v1/auth/customer/register` | Tenant | - |
| **Profile** | | | |
| GET | `/v1/profile` | Auth | Any |
| PATCH | `/v1/profile` | Auth | Any |
| PATCH | `/v1/profile/password` | Auth | Any |
| **Staff** | | | |
| GET | `/v1/staff` | Auth | owner |
| POST | `/v1/staff` | Auth | owner |
| GET | `/v1/staff/{id}` | Auth | owner |
| PATCH | `/v1/staff/{id}` | Auth | owner |
| DELETE | `/v1/staff/{id}` | Auth | owner |
| POST | `/v1/staff/{id}/permissions` | Auth | owner |
| POST | `/v1/staff/accept-invite` | Tenant | - |
| **Customers** | | | |
| GET | `/v1/customers` | Auth | owner, staff |
| GET | `/v1/customers/{id}` | Auth | owner, staff |
| PATCH | `/v1/customers/{id}` | Auth | owner, staff |
| **Categories** | | | |
| GET | `/v1/catalog/categories` | Auth | Any |
| POST | `/v1/catalog/categories` | Auth | owner, staff |
| POST | `/v1/catalog/categories/reorder` | Auth | owner, staff |
| GET | `/v1/catalog/categories/{id}` | Auth | Any |
| GET | `/v1/catalog/categories/{id}/products` | Auth | Any |
| PATCH | `/v1/catalog/categories/{id}` | Auth | owner, staff |
| DELETE | `/v1/catalog/categories/{id}` | Auth | owner |
| **Products** | | | |
| GET | `/v1/catalog/products` | Auth | Any |
| GET | `/v1/catalog/products/featured` | Auth | Any |
| GET | `/v1/catalog/search` | Auth | Any |
| POST | `/v1/catalog/products` | Auth | owner, staff |
| GET | `/v1/catalog/products/{id}` | Auth | Any |
| PATCH | `/v1/catalog/products/{id}` | Auth | owner, staff |
| DELETE | `/v1/catalog/products/{id}` | Auth | owner |
| POST | `/v1/catalog/products/{id}/publish` | Auth | owner, staff |
| POST | `/v1/catalog/products/{id}/archive` | Auth | owner, staff |
| GET | `/v1/catalog/products/{id}/calculate-price` | Auth | Any |
| GET | `/v1/catalog/products/{id}/reviews` | Auth | Any |
| **Product Media** | | | |
| POST | `/v1/catalog/products/{id}/media` | Auth | owner, staff |
| POST | `/v1/catalog/products/{id}/media/reorder` | Auth | owner, staff |
| POST | `/v1/catalog/products/{id}/media/{mediaId}/set-primary` | Auth | owner, staff |
| DELETE | `/v1/catalog/products/{id}/media/{mediaId}` | Auth | owner, staff |
| **Pricing Rules** | | | |
| GET | `/v1/catalog/products/{id}/pricing-rules` | Auth | owner, staff |
| POST | `/v1/catalog/products/{id}/pricing-rules` | Auth | owner, staff |
| PATCH | `/v1/catalog/products/{id}/pricing-rules/{ruleId}` | Auth | owner, staff |
| DELETE | `/v1/catalog/products/{id}/pricing-rules/{ruleId}` | Auth | owner, staff |
| **Assets** | | | |
| GET | `/v1/assets/products/{productId}/assets` | Auth | Any |
| POST | `/v1/assets/products/{productId}/assets` | Auth | owner, staff |
| POST | `/v1/assets/products/{productId}/assets/bulk` | Auth | owner, staff |
| GET | `/v1/assets/{assetId}` | Auth | owner, staff |
| PATCH | `/v1/assets/{assetId}` | Auth | owner, staff |
| DELETE | `/v1/assets/{assetId}` | Auth | owner |
| POST | `/v1/assets/{assetId}/status` | Auth | owner, staff |
| POST | `/v1/assets/{assetId}/condition` | Auth | owner, staff |
| GET | `/v1/assets/{assetId}/activity` | Auth | owner, staff |
| **Maintenance** | | | |
| GET | `/v1/assets/maintenance/upcoming` | Auth | owner, staff |
| GET | `/v1/assets/{assetId}/maintenance` | Auth | owner, staff |
| POST | `/v1/assets/{assetId}/maintenance` | Auth | owner, staff |
| PATCH | `/v1/assets/{assetId}/maintenance/{id}` | Auth | owner, staff |
| POST | `/v1/assets/{assetId}/maintenance/{id}/complete` | Auth | owner, staff |
| POST | `/v1/assets/{assetId}/maintenance/{id}/cancel` | Auth | owner, staff |
| DELETE | `/v1/assets/{assetId}/maintenance/{id}` | Auth | owner |
| **Availability** | | | |
| POST | `/v1/availability/check` | Tenant | - |
| POST | `/v1/availability/check-multiple` | Tenant | - |
| GET | `/v1/availability/calendar/{productId}` | Tenant | - |
| POST | `/v1/availability/lock` | Auth | Any |
| POST | `/v1/availability/lock/release` | Auth | Any |
| POST | `/v1/availability/lock/extend` | Auth | Any |
| **Bookings** | | | |
| GET | `/v1/bookings` | Auth | Any |
| POST | `/v1/bookings` | Auth | Any |
| GET | `/v1/bookings/{id}` | Auth | Any |
| POST | `/v1/bookings/{id}/confirm` | Auth | owner, staff |
| POST | `/v1/bookings/{id}/picked-up` | Auth | owner, staff |
| POST | `/v1/bookings/{id}/extend` | Auth | Any |
| POST | `/v1/bookings/{id}/cancel` | Auth | Any |
| POST | `/v1/bookings/{id}/returned` | Auth | owner, staff |
| POST | `/v1/bookings/{id}/review` | Auth | Any |
| **Inspections** | | | |
| POST | `/v1/bookings/{bookingId}/inspect` | Auth | owner, staff |
| GET | `/v1/bookings/{bookingId}/inspection` | Auth | owner, staff |
| POST | `/v1/bookings/{bookingId}/close` | Auth | owner, staff |
| GET | `/v1/bookings/{bookingId}/overdue-info` | Auth | owner, staff |
| **Payments** | | | |
| GET | `/v1/bookings/{bookingId}/payments` | Auth | Any |
| POST | `/v1/bookings/{bookingId}/payments/initiate` | Auth | Any |
| POST | `/v1/bookings/{bookingId}/payments/refund` | Auth | owner, staff |
| POST | `/v1/bookings/{bookingId}/payments/deposit/capture` | Auth | owner, staff |
| POST | `/v1/bookings/{bookingId}/payments/deposit/release` | Auth | owner, staff |
| **Notifications** | | | |
| GET | `/v1/bookings/{bookingId}/notifications` | Auth | owner, staff |
| **Deliveries** | | | |
| POST | `/v1/bookings/{bookingId}/delivery` | Auth | owner, staff |
| GET | `/v1/deliveries` | Auth | owner, staff |
| PATCH | `/v1/deliveries/{id}/status` | Auth | owner, staff |
| POST | `/v1/deliveries/{id}/assign` | Auth | owner, staff |
| **Delivery Zones** | | | |
| GET | `/v1/settings/delivery-zones` | Auth | owner |
| POST | `/v1/settings/delivery-zones` | Auth | owner |
| PATCH | `/v1/settings/delivery-zones/{id}` | Auth | owner |
| DELETE | `/v1/settings/delivery-zones/{id}` | Auth | owner |
| **Reports** | | | |
| GET | `/v1/reports/revenue` | Auth | owner, staff |
| GET | `/v1/reports/bookings` | Auth | owner, staff |
| GET | `/v1/reports/inventory` | Auth | owner, staff |
| GET | `/v1/reports/assets` | Auth | owner, staff |
| GET | `/v1/reports/customers` | Auth | owner, staff |
| GET | `/v1/reports/overdue` | Auth | owner, staff |
| **Billing** | | | |
| GET | `/v1/billing/plan` | Auth | owner |
| GET | `/v1/billing/plans` | Auth | owner |
| POST | `/v1/billing/subscribe` | Auth | owner |
| POST | `/v1/billing/cancel` | Auth | owner |
| GET | `/v1/billing/invoices` | Auth | owner |
| **Settings** | | | |
| GET | `/v1/settings/store` | Auth | owner |
| PATCH | `/v1/settings/store` | Auth | owner |
| GET | `/v1/settings/rental-policies` | Auth | owner |
| PATCH | `/v1/settings/rental-policies` | Auth | owner |
| GET | `/v1/settings/business-hours` | Auth | owner |
| PATCH | `/v1/settings/business-hours` | Auth | owner |
| GET | `/v1/settings/holidays` | Auth | owner |
| POST | `/v1/settings/holidays` | Auth | owner |
| DELETE | `/v1/settings/holidays/{id}` | Auth | owner |
| GET | `/v1/settings/notifications` | Auth | owner |
| PATCH | `/v1/settings/notifications` | Auth | owner |
| GET | `/v1/settings/payment` | Auth | owner |
| PATCH | `/v1/settings/payment` | Auth | owner |
| GET | `/v1/settings/booking` | Auth | owner |
| PATCH | `/v1/settings/booking` | Auth | owner |
| **Coupons** | | | |
| POST | `/v1/coupons/validate` | Auth | Any |
| GET | `/v1/coupons` | Auth | owner |
| POST | `/v1/coupons` | Auth | owner |
| GET | `/v1/coupons/{id}` | Auth | owner |
| PATCH | `/v1/coupons/{id}` | Auth | owner |
| DELETE | `/v1/coupons/{id}` | Auth | owner |
| **Reviews** | | | |
| GET | `/v1/reviews` | Auth | owner, staff |
| PATCH | `/v1/reviews/{id}/respond` | Auth | owner |
| PATCH | `/v1/reviews/{id}/publish` | Auth | owner |
| **Integrations** | | | |
| POST | `/v1/integrations/quickbooks/sync-invoice` | Auth | owner |
| POST | `/v1/integrations/xero/sync-invoice` | Auth | owner |
| GET | `/v1/integrations/shipping/rates` | Auth | owner |
| **Webhooks** | | | |
| GET | `/v1/settings/webhooks` | Auth | owner |
| POST | `/v1/settings/webhooks` | Auth | owner |
| GET | `/v1/settings/webhooks/{id}` | Auth | owner |
| PATCH | `/v1/settings/webhooks/{id}` | Auth | owner |
| DELETE | `/v1/settings/webhooks/{id}` | Auth | owner |
| POST | `/v1/settings/webhooks/{id}/test` | Auth | owner |
| POST | `/v1/webhooks/stripe` | None | - |

**Total: 120+ endpoints**
