command: composer show --direct --no-ansi | sed -n '1,80p'; sed -n '260,620p' specs/02-API-ROUTES.md; sed -n '120,340p' specs/06-AUTH-AND-SECURITY.md

type: unknown

stdout:
fakerphp/faker              1.24.1  Faker is a PHP library that generates f...
laravel/boost               2.5.5   Laravel Boost accelerates AI-assisted d...
laravel/fortify             1.34.1  Backend controllers and scaffolding for...
laravel/framework           12.51.0 The Laravel Framework.
laravel/pail                1.2.6   Easily delve into your Laravel applicat...
laravel/pint                1.27.1  An opinionated code formatter for PHP.
laravel/sail                1.53.0  Docker files for running a basic Larave...
laravel/tinker              2.11.1  Powerful REPL for the Laravel framework.
livewire/flux               2.12.0  The official UI component library for L...
livewire/livewire           4.1.4   A front-end framework for Laravel.
mockery/mockery             1.6.12  Mockery is a simple yet flexible PHP mo...
nunomaduro/collision        8.8.3   Cli error handling for console/command-...
pestphp/pest                4.3.2   The elegant PHP Testing Framework.
pestphp/pest-plugin-laravel 4.0.0   The Pest Laravel Plugin
- **Rate limit:** `api.storefront` (120/min per IP)

**URL parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `cartId` | integer | Yes | The cart identifier |

**Success response:** `200 OK`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 3,
  "status": "active",
  "lines": [
    {
      "id": 201,
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_subtotal_amount": 5000,
      "line_discount_amount": 0,
      "line_total_amount": 5000,
      "image_url": "https://cdn.example.com/products/tshirt-blue.jpg",
      "requires_shipping": true,
      "available_quantity": 50
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "total": 5000,
    "currency": "EUR",
    "line_count": 1,
    "item_count": 2
  },
  "created_at": "2026-02-11T10:00:00Z",
  "updated_at": "2026-02-11T10:05:00Z"
}
```

**Error responses:**
- `404` - Cart not found

---

#### POST /api/storefront/v1/carts/{cartId}/lines

Add a line item to the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "variant_id": 101,
  "quantity": 2
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |

**Success response:** `201 Created`

Returns the full cart object (same schema as GET /carts/{cartId}).

**Error responses:**
- `404` - Cart not found
- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)
- `422` - Validation error

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "variant_id": ["The selected variant is out of stock."]
  }
}
```

---

#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}

Update the quantity of a cart line.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "quantity": 3,
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |
| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |

**Success response:** `200 OK`

Returns the full cart object with updated totals.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict
- `422` - Validation error (e.g., exceeds inventory)

---

#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}

Remove a line item from the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_version` | integer | Yes | Must match current cart version. |

**Success response:** `200 OK`

Returns the full cart object.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict

---

### 2.2 Checkout Endpoints

#### Checkout Flow Sequence Diagram

```mermaid
sequenceDiagram
    participant C as Customer Browser
    participant API as Storefront API
    participant MockPSP as Mock Payment Provider

    C->>API: POST /checkouts (cart_id, email)
    API-->>C: 201 Checkout (status: started)

    C->>API: PUT /checkouts/{id}/address
    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)

    C->>API: PUT /checkouts/{id}/shipping-method
    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)

    opt Apply discount
        C->>API: POST /checkouts/{id}/apply-discount
        API-->>C: 200 Checkout (totals recalculated)
    end

    C->>API: POST /checkouts/{id}/pay (payment_method + details)
    API->>API: Reserve inventory
    API->>MockPSP: charge(checkout, paymentMethodData)

    alt Credit Card / PayPal (instant capture)
        MockPSP-->>API: PaymentResult (success)
        API->>API: Create order (paid), commit inventory
        API-->>C: 200 (order confirmation data)
    else Credit Card (decline)
        MockPSP-->>API: PaymentResult (failed)
        API->>API: Release reserved inventory
        API-->>C: 422 (error: card_declined / insufficient_funds)
    else Bank Transfer (deferred)
        MockPSP-->>API: PaymentResult (pending)
        API->>API: Create order (pending), inventory stays reserved
        API-->>C: 200 (order confirmation + bank transfer instructions)
    end
```

#### POST /api/storefront/v1/checkouts

Create a checkout from a cart.

- **Authentication:** None or session-based
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "cart_id": 42,
  "email": "customer@example.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |
| `email` | string | Yes | Valid email address. |

**Success response:** `201 Created`

```json
{
  "id": 7,
  "store_id": 1,
  "cart_id": 42,
  "customer_id": null,
  "status": "started",
  "email": "customer@example.com",
  "shipping_address_json": null,
  "billing_address_json": null,
  "shipping_method_id": null,
  "discount_code": null,
  "lines": [
    {
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_total_amount": 5000
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 0,
    "tax": 0,
    "total": 5000,
    "currency": "EUR"
  },
  "available_shipping_methods": [],
  "expires_at": "2026-02-12T10:00:00Z",
  "created_at": "2026-02-11T10:00:00Z"
}
```

**Error responses:**
- `404` - Cart not found
- `422` - Validation errors (empty cart, invalid email)

---

#### GET /api/storefront/v1/checkouts/{checkoutId}

Retrieve the current checkout state and computed totals.

- **Authentication:** None (checkout ID acts as token)
- **Rate limit:** `checkout` (10/min per session)

**Success response:** `200 OK`

Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired

---

#### PUT /api/storefront/v1/checkouts/{checkoutId}/address

Set the shipping and/or billing address on a checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address": null,
  "use_shipping_as_billing": true
}
```

**Shipping address fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |
| `shipping_address.first_name` | string | Yes | Max: 255 |
| `shipping_address.last_name` | string | Yes | Max: 255 |
| `shipping_address.address1` | string | Yes | Max: 500 |
| `shipping_address.address2` | string | No | Max: 500 |
| `shipping_address.city` | string | Yes | Max: 255 |
| `shipping_address.province` | string | No | Max: 255 |
| `shipping_address.province_code` | string | No | Max: 10 |
| `shipping_address.country` | string | Yes | Max: 255 |
| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |
| `shipping_address.postal_code` | string | Yes | Max: 20 |
| `shipping_address.phone` | string | No | Max: 50 |
| `billing_address` | object | No | Same fields as shipping_address |
| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |

**Success response:** `200 OK`

Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.

```json
{
  "id": 7,
  "status": "addressed",
  "shipping_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
### 1.2 Customer Authentication

| Setting | Value |
|---------|-------|
| Guard name | `customer` (custom guard) |
| Provider name | `customers` (Eloquent driver) |
| Model | `App\Models\Customer` |
| Driver | Session (cookie-based) |
| Login route | `GET /account/login` |
| Registration route | `GET /account/register` |

#### Customer Login Flow

```mermaid
sequenceDiagram
    participant B as Browser
    participant LW as Livewire Component
    participant Auth as Auth Guard (customer)
    participant Store as ResolveStore Middleware
    participant DB as Database

    Note over Store: Store already resolved from hostname
    B->>LW: Submit email + password
    LW->>LW: Validate input
    LW->>LW: Check rate limiter (5/min per IP)
    LW->>Auth: attempt(credentials + store_id, remember)
    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?
    Auth->>Auth: Verify bcrypt hash
    alt Valid
        Auth-->>LW: Success (regenerate session)
        LW-->>B: Redirect to /account (or intended URL)
    else Invalid
        Auth-->>LW: Failure
        LW-->>B: Flash "Invalid credentials"
    end
```

**Requirements:**

- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.
- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.
- The same email address can exist in different stores (multi-tenant isolation).
- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.
- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).

#### Customer Registration Flow

```mermaid
flowchart TD
    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]
    B --> C{Validate via RegisterCustomerRequest}
    C -- Invalid --> D[Show validation errors]
    C -- Valid --> E[Create Customer record scoped to current store]
    E --> F[Auto-login via customer guard]
    F --> G[Redirect to /account]
```

**Registration fields:**

| Field | Type | Required | Validation |
|-------|------|:--------:|------------|
| name | string | Y | max:255 |
| email | string | Y | valid email, unique per store (composite unique on email + store_id) |
| password | string | Y | min:8, confirmed |
| password_confirmation | string | Y | must match password |
| marketing_opt_in | boolean | N | defaults to false |

#### Customer Store Scoping

- The `customers` auth provider requires a custom `CustomerUserProvider` class.
- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.
- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).
- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.

#### Customer Password Reset

- Uses a separate password broker: `customers` broker.
- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
- Token records include `store_id` to scope resets to the correct store.
- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
- Token expiration: 60 minutes.
- Throttle: one reset email per 60 seconds.
- Generic response on the "forgot password" form (do not reveal whether email exists in the store).

---

### 1.3 API Authentication (Sanctum)

| Setting | Value |
|---------|-------|
| Guard name | `sanctum` |
| Provider | `users` (admin users only) |
| Token type | Personal access tokens |
| Token prefix | `shop_` |
| Hashing | SHA-256 |
| Default expiration | 1 year |
| Storage | `personal_access_tokens` table |

#### Token Lifecycle

```mermaid
stateDiagram-v2
    [*] --> Created: Admin creates token in Developers section
    Created --> Active: Plain-text shown once, hash stored
    Active --> InUse: Token sent as Bearer header
    InUse --> Active: Request processed
    Active --> Revoked: Admin revokes token
    Active --> Expired: Expiration date reached
    Revoked --> [*]: Token row deleted
    Expired --> [*]: Token row deleted (cleanup job)
```

**Requirements:**

- Tokens are created in the admin panel, under the Developers section.
- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.
- Sanctum middleware validates the hash and resolves the associated user.
- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).
- Revoking a token deletes its row from the `personal_access_tokens` table.

#### Token Abilities (Scopes)

| Ability | Description |
|---------|-------------|
| `read-products` | List and view products |
| `write-products` | Create, update, delete products |
| `read-orders` | List and view orders |
| `write-orders` | Update orders, create fulfillments |
| `read-customers` | List and view customers |
| `write-customers` | Update customers |
| `read-collections` | List and view collections |
| `write-collections` | Create, update, delete collections |
| `read-discounts` | List and view discounts |
| `write-discounts` | Create, update, delete discounts |
| `read-analytics` | View analytics data |
| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |
| `read-themes` | View themes and theme files |
| `write-themes` | Create, update, delete, publish themes |
| `read-content` | View pages and navigation menus |
| `write-content` | Create, update, delete pages and navigation items |
| `manage-platform` | Platform-level management (super-admin only) |

Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.

---

### 1.4 Guard Configuration

File to configure: `config/auth.php`

#### Guards Data Dictionary

| Guard Name | Driver | Provider | Purpose |
|------------|--------|----------|---------|
| `web` | session | `users` | Admin panel session auth (default guard) |
| `customer` | session | `customers` | Storefront customer session auth |
| `sanctum` | sanctum | `users` | Admin REST API token auth |

#### Providers Data Dictionary

| Provider Name | Driver | Model | Purpose |
|---------------|--------|-------|---------|
| `users` | eloquent | `App\Models\User` | Admin users |
| `customers` | eloquent | `App\Models\Customer` | Storefront customers (store-scoped via custom provider) |

#### Password Brokers Data Dictionary

| Broker Name | Provider | Token Table | Expiry | Throttle |
|-------------|----------|-------------|--------|----------|
| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |

**Defaults:** The default guard is `web` and the default password broker is `users`.

---

## 2. Authorization

### 2.1 Role Enum

File: `app/Enums/StoreUserRole.php`

A backed string enum with four values:

| Case | Stored Value | Description |
|------|-------------|-------------|
| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |

**Constraints:**

- Each store must have exactly one Owner.
- Ownership transfer is a dedicated action (not a simple role change).
- A User can hold different roles in different stores (via the `store_users` pivot table).

---

### 2.2 Permission Matrix

The `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.

| Resource | Action | Owner | Admin | Staff | Support |
|----------|--------|:-----:|:-----:|:-----:|:-------:|
| Store Settings | View | Y | Y | N | N |
| Store Settings | Update | Y | Y | N | N |
| Store | Delete | Y | N | N | N |
| Products | List / View | Y | Y | Y | Y |
| Products | Create | Y | Y | Y | N |
| Products | Update | Y | Y | Y | N |
| Products | Delete / Archive | Y | Y | N | N |
| Products | Restore | Y | Y | N | N |
| Collections | List / View | Y | Y | Y | Y |
| Collections | Create | Y | Y | Y | N |
| Collections | Update | Y | Y | Y | N |
| Collections | Delete | Y | Y | N | N |
| Orders | List / View | Y | Y | Y | Y |


text:
Script completed
Wall time 0.2 seconds
Output:

text:
fakerphp/faker              1.24.1  Faker is a PHP library that generates f...
laravel/boost               2.5.5   Laravel Boost accelerates AI-assisted d...
laravel/fortify             1.34.1  Backend controllers and scaffolding for...
laravel/framework           12.51.0 The Laravel Framework.
laravel/pail                1.2.6   Easily delve into your Laravel applicat...
laravel/pint                1.27.1  An opinionated code formatter for PHP.
laravel/sail                1.53.0  Docker files for running a basic Larave...
laravel/tinker              2.11.1  Powerful REPL for the Laravel framework.
livewire/flux               2.12.0  The official UI component library for L...
livewire/livewire           4.1.4   A front-end framework for Laravel.
mockery/mockery             1.6.12  Mockery is a simple yet flexible PHP mo...
nunomaduro/collision        8.8.3   Cli error handling for console/command-...
pestphp/pest                4.3.2   The elegant PHP Testing Framework.
pestphp/pest-plugin-laravel 4.0.0   The Pest Laravel Plugin
- **Rate limit:** `api.storefront` (120/min per IP)

**URL parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `cartId` | integer | Yes | The cart identifier |

**Success response:** `200 OK`

```json
{
  "id": 42,
  "store_id": 1,
  "customer_id": null,
  "currency": "EUR",
  "cart_version": 3,
  "status": "active",
  "lines": [
    {
      "id": 201,
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_subtotal_amount": 5000,
      "line_discount_amount": 0,
      "line_total_amount": 5000,
      "image_url": "https://cdn.example.com/products/tshirt-blue.jpg",
      "requires_shipping": true,
      "available_quantity": 50
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "total": 5000,
    "currency": "EUR",
    "line_count": 1,
    "item_count": 2
  },
  "created_at": "2026-02-11T10:00:00Z",
  "updated_at": "2026-02-11T10:05:00Z"
}
```

**Error responses:**
- `404` - Cart not found

---

#### POST /api/storefront/v1/carts/{cartId}/lines

Add a line item to the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "variant_id": 101,
  "quantity": 2
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `variant_id` | integer | Yes | Must exist, belong to an active product in the current store |
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |

**Success response:** `201 Created`

Returns the full cart object (same schema as GET /carts/{cartId}).

**Error responses:**
- `404` - Cart not found
- `409` - Cart version conflict (concurrent modification, retry with fresh cart_version)
- `422` - Validation error

```json
{
  "message": "The given data was invalid.",
  "errors": {
    "variant_id": ["The selected variant is out of stock."]
  }
}
```

---

#### PUT /api/storefront/v1/carts/{cartId}/lines/{lineId}

Update the quantity of a cart line.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "quantity": 3,
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `quantity` | integer | Yes | Min: 1, max: 9999. Must not exceed available inventory when policy is `deny`. |
| `cart_version` | integer | Yes | Must match current cart version for optimistic concurrency. |

**Success response:** `200 OK`

Returns the full cart object with updated totals.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict
- `422` - Validation error (e.g., exceeds inventory)

---

#### DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId}

Remove a line item from the cart.

- **Authentication:** None
- **Rate limit:** `api.storefront` (120/min per IP)

**Request body:**

```json
{
  "cart_version": 3
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_version` | integer | Yes | Must match current cart version. |

**Success response:** `200 OK`

Returns the full cart object.

**Error responses:**
- `404` - Cart or line not found
- `409` - Version conflict

---

### 2.2 Checkout Endpoints

#### Checkout Flow Sequence Diagram

```mermaid
sequenceDiagram
    participant C as Customer Browser
    participant API as Storefront API
    participant MockPSP as Mock Payment Provider

    C->>API: POST /checkouts (cart_id, email)
    API-->>C: 201 Checkout (status: started)

    C->>API: PUT /checkouts/{id}/address
    API-->>C: 200 Checkout (status: addressed, available_shipping_methods)

    C->>API: PUT /checkouts/{id}/shipping-method
    API-->>C: 200 Checkout (status: shipping_selected, totals with tax)

    opt Apply discount
        C->>API: POST /checkouts/{id}/apply-discount
        API-->>C: 200 Checkout (totals recalculated)
    end

    C->>API: POST /checkouts/{id}/pay (payment_method + details)
    API->>API: Reserve inventory
    API->>MockPSP: charge(checkout, paymentMethodData)

    alt Credit Card / PayPal (instant capture)
        MockPSP-->>API: PaymentResult (success)
        API->>API: Create order (paid), commit inventory
        API-->>C: 200 (order confirmation data)
    else Credit Card (decline)
        MockPSP-->>API: PaymentResult (failed)
        API->>API: Release reserved inventory
        API-->>C: 422 (error: card_declined / insufficient_funds)
    else Bank Transfer (deferred)
        MockPSP-->>API: PaymentResult (pending)
        API->>API: Create order (pending), inventory stays reserved
        API-->>C: 200 (order confirmation + bank transfer instructions)
    end
```

#### POST /api/storefront/v1/checkouts

Create a checkout from a cart.

- **Authentication:** None or session-based
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "cart_id": 42,
  "email": "customer@example.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `cart_id` | integer | Yes | Must reference an active cart with at least one line. |
| `email` | string | Yes | Valid email address. |

**Success response:** `201 Created`

```json
{
  "id": 7,
  "store_id": 1,
  "cart_id": 42,
  "customer_id": null,
  "status": "started",
  "email": "customer@example.com",
  "shipping_address_json": null,
  "billing_address_json": null,
  "shipping_method_id": null,
  "discount_code": null,
  "lines": [
    {
      "variant_id": 101,
      "product_title": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "line_total_amount": 5000
    }
  ],
  "totals": {
    "subtotal": 5000,
    "discount": 0,
    "shipping": 0,
    "tax": 0,
    "total": 5000,
    "currency": "EUR"
  },
  "available_shipping_methods": [],
  "expires_at": "2026-02-12T10:00:00Z",
  "created_at": "2026-02-11T10:00:00Z"
}
```

**Error responses:**
- `404` - Cart not found
- `422` - Validation errors (empty cart, invalid email)

---

#### GET /api/storefront/v1/checkouts/{checkoutId}

Retrieve the current checkout state and computed totals.

- **Authentication:** None (checkout ID acts as token)
- **Rate limit:** `checkout` (10/min per session)

**Success response:** `200 OK`

Returns the full checkout object (same schema as POST response, with updated fields reflecting the current state).

**Error responses:**
- `404` - Checkout not found
- `410` - Checkout expired

---

#### PUT /api/storefront/v1/checkouts/{checkoutId}/address

Set the shipping and/or billing address on a checkout.

- **Authentication:** None
- **Rate limit:** `checkout` (10/min per session)

**Request body:**

```json
{
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address": null,
  "use_shipping_as_billing": true
}
```

**Shipping address fields:**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `shipping_address` | object | Yes (if any line requires shipping) | See fields below |
| `shipping_address.first_name` | string | Yes | Max: 255 |
| `shipping_address.last_name` | string | Yes | Max: 255 |
| `shipping_address.address1` | string | Yes | Max: 500 |
| `shipping_address.address2` | string | No | Max: 500 |
| `shipping_address.city` | string | Yes | Max: 255 |
| `shipping_address.province` | string | No | Max: 255 |
| `shipping_address.province_code` | string | No | Max: 10 |
| `shipping_address.country` | string | Yes | Max: 255 |
| `shipping_address.country_code` | string | Yes | Valid ISO 3166-1 alpha-2 |
| `shipping_address.postal_code` | string | Yes | Max: 20 |
| `shipping_address.phone` | string | No | Max: 50 |
| `billing_address` | object | No | Same fields as shipping_address |
| `use_shipping_as_billing` | boolean | No | Default: true. If true, billing_address is ignored and copied from shipping. |

**Success response:** `200 OK`

Returns the full checkout object. Status transitions to `addressed`. The response includes `available_shipping_methods` populated based on the shipping address and store's shipping zones.

```json
{
  "id": 7,
  "status": "addressed",
  "shipping_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
    "phone": "+49301234567"
  },
  "billing_address_json": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "address2": "Apt 4B",
    "city": "Berlin",
    "province": "Berlin",
    "province_code": "BE",
    "country": "DE",
    "country_code": "DE",
    "postal_code": "10115",
### 1.2 Customer Authentication

| Setting | Value |
|---------|-------|
| Guard name | `customer` (custom guard) |
| Provider name | `customers` (Eloquent driver) |
| Model | `App\Models\Customer` |
| Driver | Session (cookie-based) |
| Login route | `GET /account/login` |
| Registration route | `GET /account/register` |

#### Customer Login Flow

```mermaid
sequenceDiagram
    participant B as Browser
    participant LW as Livewire Component
    participant Auth as Auth Guard (customer)
    participant Store as ResolveStore Middleware
    participant DB as Database

    Note over Store: Store already resolved from hostname
    B->>LW: Submit email + password
    LW->>LW: Validate input
    LW->>LW: Check rate limiter (5/min per IP)
    LW->>Auth: attempt(credentials + store_id, remember)
    Auth->>DB: SELECT from customers WHERE email = ? AND store_id = ?
    Auth->>Auth: Verify bcrypt hash
    alt Valid
        Auth-->>LW: Success (regenerate session)
        LW-->>B: Redirect to /account (or intended URL)
    else Invalid
        Auth-->>LW: Failure
        LW-->>B: Flash "Invalid credentials"
    end
```

**Requirements:**

- Customer login is always scoped to the current store. The store is resolved from the request hostname by the `ResolveStore` middleware before the login attempt.
- The custom `CustomerUserProvider` must inject `store_id` into the credential check so the query filters by both `email` and `store_id`.
- The same email address can exist in different stores (multi-tenant isolation).
- Rate limiting: maximum 5 attempts per minute per IP, using the same `login` rate limiter.
- After login: redirect to `/account` or the intended URL (if the customer was redirected from a protected page).

#### Customer Registration Flow

```mermaid
flowchart TD
    A[Customer visits /account/register] --> B[Fill form: name, email, password, password_confirmation, marketing_opt_in]
    B --> C{Validate via RegisterCustomerRequest}
    C -- Invalid --> D[Show validation errors]
    C -- Valid --> E[Create Customer record scoped to current store]
    E --> F[Auto-login via customer guard]
    F --> G[Redirect to /account]
```

**Registration fields:**

| Field | Type | Required | Validation |
|-------|------|:--------:|------------|
| name | string | Y | max:255 |
| email | string | Y | valid email, unique per store (composite unique on email + store_id) |
| password | string | Y | min:8, confirmed |
| password_confirmation | string | Y | must match password |
| marketing_opt_in | boolean | N | defaults to false |

#### Customer Store Scoping

- The `customers` auth provider requires a custom `CustomerUserProvider` class.
- This provider overrides the default Eloquent provider to always include `store_id` in credential queries.
- The `store_id` is obtained from the container-bound current store (resolved by `ResolveStore` middleware).
- This ensures that `Auth::guard('customer')->attempt()` never crosses store boundaries.

#### Customer Password Reset

- Uses a separate password broker: `customers` broker.
- Tokens stored in the `customer_password_reset_tokens` table (separate from admin tokens).
- Token records include `store_id` to scope resets to the correct store.
- Routes: `GET /forgot-password`, `POST /forgot-password`, `GET /reset-password/{token}`, `POST /reset-password`.
- Token expiration: 60 minutes.
- Throttle: one reset email per 60 seconds.
- Generic response on the "forgot password" form (do not reveal whether email exists in the store).

---

### 1.3 API Authentication (Sanctum)

| Setting | Value |
|---------|-------|
| Guard name | `sanctum` |
| Provider | `users` (admin users only) |
| Token type | Personal access tokens |
| Token prefix | `shop_` |
| Hashing | SHA-256 |
| Default expiration | 1 year |
| Storage | `personal_access_tokens` table |

#### Token Lifecycle

```mermaid
stateDiagram-v2
    [*] --> Created: Admin creates token in Developers section
    Created --> Active: Plain-text shown once, hash stored
    Active --> InUse: Token sent as Bearer header
    InUse --> Active: Request processed
    Active --> Revoked: Admin revokes token
    Active --> Expired: Expiration date reached
    Revoked --> [*]: Token row deleted
    Expired --> [*]: Token row deleted (cleanup job)
```

**Requirements:**

- Tokens are created in the admin panel, under the Developers section.
- Each token has: a human-readable name, an optional expiration date, and a list of abilities (scopes).
- On creation, the plain-text token (prefixed with `shop_`) is displayed exactly once. Only the SHA-256 hash is persisted.
- Tokens are sent as `Authorization: Bearer shop_xxxxxxxx`.
- Sanctum middleware validates the hash and resolves the associated user.
- Ability checks are performed per-endpoint (e.g., the products list endpoint requires `read-products`).
- Revoking a token deletes its row from the `personal_access_tokens` table.

#### Token Abilities (Scopes)

| Ability | Description |
|---------|-------------|
| `read-products` | List and view products |
| `write-products` | Create, update, delete products |
| `read-orders` | List and view orders |
| `write-orders` | Update orders, create fulfillments |
| `read-customers` | List and view customers |
| `write-customers` | Update customers |
| `read-collections` | List and view collections |
| `write-collections` | Create, update, delete collections |
| `read-discounts` | List and view discounts |
| `write-discounts` | Create, update, delete discounts |
| `read-analytics` | View analytics data |
| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |
| `read-themes` | View themes and theme files |
| `write-themes` | Create, update, delete, publish themes |
| `read-content` | View pages and navigation menus |
| `write-content` | Create, update, delete pages and navigation items |
| `manage-platform` | Platform-level management (super-admin only) |

Each API endpoint must check the token's abilities before processing the request. If the token lacks the required ability, the endpoint returns `403 Forbidden`.

---

### 1.4 Guard Configuration

File to configure: `config/auth.php`

#### Guards Data Dictionary

| Guard Name | Driver | Provider | Purpose |
|------------|--------|----------|---------|
| `web` | session | `users` | Admin panel session auth (default guard) |
| `customer` | session | `customers` | Storefront customer session auth |
| `sanctum` | sanctum | `users` | Admin REST API token auth |

#### Providers Data Dictionary

| Provider Name | Driver | Model | Purpose |
|---------------|--------|-------|---------|
| `users` | eloquent | `App\Models\User` | Admin users |
| `customers` | eloquent | `App\Models\Customer` | Storefront customers (store-scoped via custom provider) |

#### Password Brokers Data Dictionary

| Broker Name | Provider | Token Table | Expiry | Throttle |
|-------------|----------|-------------|--------|----------|
| `users` | `users` | `password_reset_tokens` | 60 min | 60 sec |
| `customers` | `customers` | `customer_password_reset_tokens` | 60 min | 60 sec |

**Defaults:** The default guard is `web` and the default password broker is `users`.

---

## 2. Authorization

### 2.1 Role Enum

File: `app/Enums/StoreUserRole.php`

A backed string enum with four values:

| Case | Stored Value | Description |
|------|-------------|-------------|
| Owner | `'owner'` | Full access. Can transfer ownership, manage billing, delete store. One per store. |
| Admin | `'admin'` | Near-full access. Cannot transfer ownership or delete store. |
| Staff | `'staff'` | Operational access. Can manage products, orders, collections, discounts, pages. Cannot access settings, themes, staff management, or developers. |
| Support | `'support'` | Read-only access to products, orders, and customers. For customer service agents. |

**Constraints:**

- Each store must have exactly one Owner.
- Ownership transfer is a dedicated action (not a simple role change).
- A User can hold different roles in different stores (via the `store_users` pivot table).

---

### 2.2 Permission Matrix

The `store_users` pivot table links a `User` to a `Store` with a `role`. A user can have different roles in different stores.

| Resource | Action | Owner | Admin | Staff | Support |
|----------|--------|:-----:|:-----:|:-----:|:-------:|
| Store Settings | View | Y | Y | N | N |
| Store Settings | Update | Y | Y | N | N |
| Store | Delete | Y | N | N | N |
| Products | List / View | Y | Y | Y | Y |
| Products | Create | Y | Y | Y | N |
| Products | Update | Y | Y | Y | N |
| Products | Delete / Archive | Y | Y | N | N |
| Products | Restore | Y | Y | N | N |
| Collections | List / View | Y | Y | Y | Y |
| Collections | Create | Y | Y | Y | N |
| Collections | Update | Y | Y | Y | N |
| Collections | Delete | Y | Y | N | N |
| Orders | List / View | Y | Y | Y | Y |


exec

status: completed

input:
const r = await tools.exec_command({cmd:"composer show laravel/sanctum --no-ansi 2>&1 || true; sed -n '1,240p' config/auth.php; sed -n '1,240p' app/Http/Controllers/Api/AdminController.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":20000});
text(r.output);


type: CommandExecution

command: composer show laravel/sanctum --no-ansi 2>&1 || true; sed -n '1,240p' config/auth.php; sed -n '1,240p' app/Http/Controllers/Api/AdminController.php

status: completed

stdout:
name     : laravel/sanctum
descrip. : Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.
keywords : auth, laravel, sanctum
versions : * v4.3.3
released : 2026-06-23, 1 month ago
type     : library
license  : MIT License (MIT) (OSI approved) https://spdx.org/licenses/MIT.html#licenseText
homepage :
source   : [git] https://github.com/laravel/sanctum.git fee27a573d1a013af3721d86153a65e0b11927e6
dist     : [zip] https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6 fee27a573d1a013af3721d86153a65e0b11927e6
path     : /Users/wesner/Herd/shop/vendor/laravel/sanctum
names    : laravel/sanctum

support
issues : https://github.com/laravel/sanctum/issues
source : https://github.com/laravel/sanctum

autoload
psr-4
Laravel\Sanctum\ => src/

requires
ext-json *
illuminate/console ^11.0|^12.0|^13.0
illuminate/contracts ^11.0|^12.0|^13.0
illuminate/database ^11.0|^12.0|^13.0
illuminate/support ^11.0|^12.0|^13.0
php ^8.2
symfony/console ^7.0|^8.0

requires (dev)
mockery/mockery ^1.6
orchestra/testbench ^9.15|^10.8|^11.0
phpstan/phpstan ^1.10
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];
<?php

namespace App\Http\Controllers\Api;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Services\FulfillmentService;
use App\Services\ProductService;
use App\Services\RefundService;
use App\Services\SearchService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AdminController extends Controller
{
    public function products(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'in:draft,active,archived'], 'query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'collections'])->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('title', 'like', '%'.$queryText.'%')->orWhere('vendor', 'like', '%'.$queryText.'%')->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', '%'.$queryText.'%'));
        }))->latest('updated_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($products);
    }

    public function storeProduct(Request $request, int $storeId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'in:draft,active,archived'], 'variants' => ['nullable', 'array']]);
        $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]);

        return response()->json(['data' => $product->load('variants')->toArray()], 201);
    }

    public function showProduct(int $storeId, int $productId): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId);

        return response()->json(['data' => $product->toArray()]);
    }

    public function updateProduct(Request $request, int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description' => ['sometimes', 'nullable', 'string'], 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => ['sometimes', 'in:draft,active,archived']]);

        return response()->json(['data' => $products->update($product, $data)->toArray()]);
    }

    public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $products->transitionStatus($product, ProductStatus::Archived);

        return response()->json(['message' => 'Product archived']);
    }

    public function collections(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $collections = Collection::withoutGlobalScopes()->where('store_id', $storeId)->withCount('products')->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where('title', 'like', '%'.$queryText.'%'))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($collections);
    }

    public function storeCollection(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'status' => ['nullable', 'in:draft,active,archived'], 'product_ids' => ['nullable', 'array']]);
        $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']);
        $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId);

        return response()->json(['data' => $collection->load('products')->toArray()], 201);
    }

    public function updateCollection(Request $request, int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId);
        $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array']]);
        $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null));

        if (array_key_exists('product_ids', $data)) {
            $this->syncCollectionProducts($collection, $data['product_ids'], $storeId);
        }

        return response()->json(['data' => $collection->load('products')->toArray()]);
    }

    public function deleteCollection(int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId)->delete();

        return response()->json(['message' => 'Collection deleted']);
    }

    public function orders(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'string'], 'financial_status' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('customer')->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status))->latest('placed_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($orders);
    }

    public function showOrder(int $storeId, int $orderId): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with(['customer', 'lines', 'payments', 'refunds', 'fulfillments.lines'])->findOrFail($orderId);

        return response()->json(['data' => $order->toArray()]);
    }

    public function customers(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $customers = Customer::withoutGlobalScopes()->where('store_id', $storeId)->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('email', 'like', '%'.$queryText.'%')->orWhere('first_name', 'like', '%'.$queryText.'%')->orWhere('last_name', 'like', '%'.$queryText.'%');
        }))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($customers);
    }

    public function discounts(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25)));
    }

    public function storeDiscount(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['code' => ['nullable', 'string', 'max:64'], 'type' => ['required', 'string'], 'value_type' => ['required', 'in:percent,fixed,free_shipping'], 'value_amount' => ['required', 'integer', 'min:0'], 'status' => ['nullable', 'in:active,inactive'], 'usage_limit' => ['nullable', 'integer', 'min:1'], 'starts_at' => ['nullable', 'date'], 'ends_at' => ['nullable', 'date'], 'rules_json' => ['nullable', 'array']]);
        $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]);

        return response()->json(['data' => $discount], 201);
    }

    public function updateDiscount(Request $request, int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId);
        $discount->update($request->validate(['code' => ['sometimes', 'nullable', 'string', 'max:64'], 'type' => ['sometimes', 'string'], 'value_type' => ['sometimes', 'in:percent,fixed,free_shipping'], 'value_amount' => ['sometimes', 'integer', 'min:0'], 'status' => ['sometimes', 'in:active,inactive'], 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], 'starts_at' => ['sometimes', 'nullable', 'date'], 'ends_at' => ['sometimes', 'nullable', 'date'], 'rules_json' => ['sometimes', 'array']]));

        return response()->json(['data' => $discount->refresh()]);
    }

    public function deleteDiscount(int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId)->delete();

        return response()->json(['message' => 'Discount deleted.']);
    }

    public function fulfillOrder(Request $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId);
        $data = $request->validate(['lines' => ['required', 'array', 'min:1'], 'lines.*.order_line_id' => ['required', 'integer'], 'lines.*.quantity' => ['required', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string', 'max:100'], 'tracking_number' => ['nullable', 'string', 'max:255'], 'tracking_url' => ['nullable', 'url']]);
        $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null]));

        return response()->json(['data' => $fulfillment->toArray()], 201);
    }

    public function refundOrder(Request $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('payments')->findOrFail($orderId);
        $data = $request->validate(['payment_id' => ['nullable', 'integer'], 'amount' => ['nullable', 'integer', 'min:1'], 'lines' => ['nullable', 'array'], 'reason' => ['nullable', 'string', 'max:500'], 'restock' => ['nullable', 'boolean']]);
        $payment = $order->payments->firstWhere('id', $data['payment_id'] ?? null) ?? $order->payments->firstWhere('status', 'captured');
        abort_unless($payment !== null, 422, 'No captured payment is available.');
        $refund = $refunds->create($order, $payment, $data['lines'] ?? $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? []);

        return response()->json(['data' => $refund->toArray()], 201);
    }

    public function shippingZones(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);
    }

    public function storeShippingZone(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']]);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId])], 201);
    }

    public function updateShippingZone(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $zone->update($request->validate(['name' => ['sometimes', 'string', 'max:255'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']]));

        return response()->json(['data' => $zone->refresh()]);
    }

    public function storeShippingRate(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']]);

        return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201);
    }

    public function taxSettings(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $storeId])]);
    }

    public function updateTaxSettings(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);


text:
Script completed
Wall time 0.2 seconds
Output:

text:
name     : laravel/sanctum
descrip. : Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.
keywords : auth, laravel, sanctum
versions : * v4.3.3
released : 2026-06-23, 1 month ago
type     : library
license  : MIT License (MIT) (OSI approved) https://spdx.org/licenses/MIT.html#licenseText
homepage :
source   : [git] https://github.com/laravel/sanctum.git fee27a573d1a013af3721d86153a65e0b11927e6
dist     : [zip] https://api.github.com/repos/laravel/sanctum/zipball/fee27a573d1a013af3721d86153a65e0b11927e6 fee27a573d1a013af3721d86153a65e0b11927e6
path     : /Users/wesner/Herd/shop/vendor/laravel/sanctum
names    : laravel/sanctum

support
issues : https://github.com/laravel/sanctum/issues
source : https://github.com/laravel/sanctum

autoload
psr-4
Laravel\Sanctum\ => src/

requires
ext-json *
illuminate/console ^11.0|^12.0|^13.0
illuminate/contracts ^11.0|^12.0|^13.0
illuminate/database ^11.0|^12.0|^13.0
illuminate/support ^11.0|^12.0|^13.0
php ^8.2
symfony/console ^7.0|^8.0

requires (dev)
mockery/mockery ^1.6
orchestra/testbench ^9.15|^10.8|^11.0
phpstan/phpstan ^1.10
<?php

return [

    /*
    |--------------------------------------------------------------------------
    | Authentication Defaults
    |--------------------------------------------------------------------------
    |
    | This option defines the default authentication "guard" and password
    | reset "broker" for your application. You may change these values
    | as required, but they're a perfect start for most applications.
    |
    */

    'defaults' => [
        'guard' => env('AUTH_GUARD', 'web'),
        'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
    ],

    /*
    |--------------------------------------------------------------------------
    | Authentication Guards
    |--------------------------------------------------------------------------
    |
    | Next, you may define every authentication guard for your application.
    | Of course, a great default configuration has been defined for you
    | which utilizes session storage plus the Eloquent user provider.
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | Supported: "session"
    |
    */

    'guards' => [
        'web' => [
            'driver' => 'session',
            'provider' => 'users',
        ],
        'customer' => [
            'driver' => 'session',
            'provider' => 'customers',
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | User Providers
    |--------------------------------------------------------------------------
    |
    | All authentication guards have a user provider, which defines how the
    | users are actually retrieved out of your database or other storage
    | system used by the application. Typically, Eloquent is utilized.
    |
    | If you have multiple user tables or models you may configure multiple
    | providers to represent the model / table. These providers may then
    | be assigned to any extra authentication guards you have defined.
    |
    | Supported: "database", "eloquent"
    |
    */

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => env('AUTH_MODEL', App\Models\User::class),
        ],
        'customers' => [
            'driver' => 'customer',
            'model' => App\Models\Customer::class,
        ],

        // 'users' => [
        //     'driver' => 'database',
        //     'table' => 'users',
        // ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Resetting Passwords
    |--------------------------------------------------------------------------
    |
    | These configuration options specify the behavior of Laravel's password
    | reset functionality, including the table utilized for token storage
    | and the user provider that is invoked to actually retrieve users.
    |
    | The expiry time is the number of minutes that each reset token will be
    | considered valid. This security feature keeps tokens short-lived so
    | they have less time to be guessed. You may change this as needed.
    |
    | The throttle setting is the number of seconds a user must wait before
    | generating more password reset tokens. This prevents the user from
    | quickly generating a very large amount of password reset tokens.
    |
    */

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
            'expire' => 60,
            'throttle' => 60,
        ],
        'customers' => [
            'provider' => 'customers',
            'table' => 'customer_password_reset_tokens',
            'expire' => 60,
            'throttle' => 60,
        ],
    ],

    /*
    |--------------------------------------------------------------------------
    | Password Confirmation Timeout
    |--------------------------------------------------------------------------
    |
    | Here you may define the number of seconds before a password confirmation
    | window expires and users are asked to re-enter their password via the
    | confirmation screen. By default, the timeout lasts for three hours.
    |
    */

    'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),

];
<?php

namespace App\Http\Controllers\Api;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Services\FulfillmentService;
use App\Services\ProductService;
use App\Services\RefundService;
use App\Services\SearchService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

class AdminController extends Controller
{
    public function products(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'in:draft,active,archived'], 'query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'collections'])->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('title', 'like', '%'.$queryText.'%')->orWhere('vendor', 'like', '%'.$queryText.'%')->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', '%'.$queryText.'%'));
        }))->latest('updated_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($products);
    }

    public function storeProduct(Request $request, int $storeId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description' => ['nullable', 'string'], 'vendor' => ['nullable', 'string', 'max:255'], 'product_type' => ['nullable', 'string', 'max:255'], 'status' => ['nullable', 'in:draft,active,archived'], 'variants' => ['nullable', 'array']]);
        $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]);

        return response()->json(['data' => $product->load('variants')->toArray()], 201);
    }

    public function showProduct(int $storeId, int $productId): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->with(['variants.inventory', 'options.values', 'media', 'collections'])->findOrFail($productId);

        return response()->json(['data' => $product->toArray()]);
    }

    public function updateProduct(Request $request, int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description' => ['sometimes', 'nullable', 'string'], 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => ['sometimes', 'in:draft,active,archived']]);

        return response()->json(['data' => $products->update($product, $data)->toArray()]);
    }

    public function deleteProduct(int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $products->transitionStatus($product, ProductStatus::Archived);

        return response()->json(['message' => 'Product archived']);
    }

    public function collections(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $collections = Collection::withoutGlobalScopes()->where('store_id', $storeId)->withCount('products')->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where('title', 'like', '%'.$queryText.'%'))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($collections);
    }

    public function storeCollection(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'description_html' => ['nullable', 'string'], 'status' => ['nullable', 'in:draft,active,archived'], 'product_ids' => ['nullable', 'array']]);
        $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']);
        $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId);

        return response()->json(['data' => $collection->load('products')->toArray()], 201);
    }

    public function updateCollection(Request $request, int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId);
        $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'description_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,active,archived'], 'product_ids' => ['sometimes', 'array']]);
        $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null));

        if (array_key_exists('product_ids', $data)) {
            $this->syncCollectionProducts($collection, $data['product_ids'], $storeId);
        }

        return response()->json(['data' => $collection->load('products')->toArray()]);
    }

    public function deleteCollection(int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId)->delete();

        return response()->json(['message' => 'Collection deleted']);
    }

    public function orders(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['status' => ['nullable', 'string'], 'financial_status' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('customer')->when($data['status'] ?? null, fn ($query, string $status) => $query->where('status', $status))->when($data['financial_status'] ?? null, fn ($query, string $status) => $query->where('financial_status', $status))->latest('placed_at')->paginate($data['per_page'] ?? 25);

        return $this->paginated($orders);
    }

    public function showOrder(int $storeId, int $orderId): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with(['customer', 'lines', 'payments', 'refunds', 'fulfillments.lines'])->findOrFail($orderId);

        return response()->json(['data' => $order->toArray()]);
    }

    public function customers(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['query' => ['nullable', 'string'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:100']]);
        $customers = Customer::withoutGlobalScopes()->where('store_id', $storeId)->when($data['query'] ?? null, fn ($query, string $queryText) => $query->where(function ($nested) use ($queryText): void {
            $nested->where('email', 'like', '%'.$queryText.'%')->orWhere('first_name', 'like', '%'.$queryText.'%')->orWhere('last_name', 'like', '%'.$queryText.'%');
        }))->latest()->paginate($data['per_page'] ?? 25);

        return $this->paginated($customers);
    }

    public function discounts(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return $this->paginated(Discount::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25)));
    }

    public function storeDiscount(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['code' => ['nullable', 'string', 'max:64'], 'type' => ['required', 'string'], 'value_type' => ['required', 'in:percent,fixed,free_shipping'], 'value_amount' => ['required', 'integer', 'min:0'], 'status' => ['nullable', 'in:active,inactive'], 'usage_limit' => ['nullable', 'integer', 'min:1'], 'starts_at' => ['nullable', 'date'], 'ends_at' => ['nullable', 'date'], 'rules_json' => ['nullable', 'array']]);
        $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]);

        return response()->json(['data' => $discount], 201);
    }

    public function updateDiscount(Request $request, int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId);
        $discount->update($request->validate(['code' => ['sometimes', 'nullable', 'string', 'max:64'], 'type' => ['sometimes', 'string'], 'value_type' => ['sometimes', 'in:percent,fixed,free_shipping'], 'value_amount' => ['sometimes', 'integer', 'min:0'], 'status' => ['sometimes', 'in:active,inactive'], 'usage_limit' => ['sometimes', 'nullable', 'integer', 'min:1'], 'starts_at' => ['sometimes', 'nullable', 'date'], 'ends_at' => ['sometimes', 'nullable', 'date'], 'rules_json' => ['sometimes', 'array']]));

        return response()->json(['data' => $discount->refresh()]);
    }

    public function deleteDiscount(int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId)->delete();

        return response()->json(['message' => 'Discount deleted.']);
    }

    public function fulfillOrder(Request $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId);
        $data = $request->validate(['lines' => ['required', 'array', 'min:1'], 'lines.*.order_line_id' => ['required', 'integer'], 'lines.*.quantity' => ['required', 'integer', 'min:1'], 'tracking_company' => ['nullable', 'string', 'max:100'], 'tracking_number' => ['nullable', 'string', 'max:255'], 'tracking_url' => ['nullable', 'url']]);
        $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null]));

        return response()->json(['data' => $fulfillment->toArray()], 201);
    }

    public function refundOrder(Request $request, int $storeId, int $orderId, RefundService $refunds): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->with('payments')->findOrFail($orderId);
        $data = $request->validate(['payment_id' => ['nullable', 'integer'], 'amount' => ['nullable', 'integer', 'min:1'], 'lines' => ['nullable', 'array'], 'reason' => ['nullable', 'string', 'max:500'], 'restock' => ['nullable', 'boolean']]);
        $payment = $order->payments->firstWhere('id', $data['payment_id'] ?? null) ?? $order->payments->firstWhere('status', 'captured');
        abort_unless($payment !== null, 422, 'No captured payment is available.');
        $refund = $refunds->create($order, $payment, $data['lines'] ?? $data['amount'] ?? null, $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? []);

        return response()->json(['data' => $refund->toArray()], 201);
    }

    public function shippingZones(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);
    }

    public function storeShippingZone(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'countries_json' => ['nullable', 'array'], 'regions_json' => ['nullable', 'array']]);

        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId])], 201);
    }

    public function updateShippingZone(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $zone->update($request->validate(['name' => ['sometimes', 'string', 'max:255'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']]));

        return response()->json(['data' => $zone->refresh()]);
    }

    public function storeShippingRate(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'price_amount' => ['required', 'integer', 'min:0'], 'currency' => ['required', 'size:3'], 'config_json' => ['nullable', 'array'], 'is_active' => ['nullable', 'boolean'], 'estimated_days_min' => ['nullable', 'integer', 'min:0'], 'estimated_days_max' => ['nullable', 'integer', 'min:0']]);

        return response()->json(['data' => ShippingRate::create([...$data, 'shipping_zone_id' => $zoneId, 'is_active' => $data['is_active'] ?? true])], 201);
    }

    public function taxSettings(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->firstOrCreate(['store_id' => $storeId])]);
    }

    public function updateTaxSettings(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);


status: completed

input:
const r = await tools.exec_command({cmd:"rg -n \"personal_access|Sanctum|HasApiTokens|api.admin|admin/v1|oauth|audit|webhook\" app config database routes composer.json bootstrap | head -200; sed -n '1,260p' routes/api.php; sed -n '240,520p' app/Http/Controllers/Api/AdminController.php","workdir":"/Users/wesner/Herd/shop","yield_time_ms":1000,"max_output_tokens":20000});
text(r.output);


command: rg -n "personal_access|Sanctum|HasApiTokens|api.admin|admin/v1|oauth|audit|webhook" app config database routes composer.json bootstrap | head -200; sed -n '1,260p' routes/api.php; sed -n '240,520p' app/Http/Controllers/Api/AdminController.php

type: search

cmd: rg -n 'personal_access|Sanctum|HasApiTokens|api.admin|admin/v1|oauth|audit|webhook' app config database routes composer.json bootstrap

query: personal_access|Sanctum|HasApiTokens|api.admin|admin/v1|oauth|audit|webhook

path: app

api.php

type: read

cmd: sed -n '1,260p' routes/api.php

AdminController.php

stdout:
routes/api.php:32:Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:549:        Schema::create('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:561:        Schema::create('webhook_deliveries', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:563:            $table->foreignId('webhook_subscription_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:12:        Schema::create('personal_access_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_220000_add_search_and_auth_support.php:36:        Schema::dropIfExists('personal_access_tokens');
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:81:        Schema::table('webhook_subscriptions', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:82:            if (! Schema::hasColumn('webhook_subscriptions', 'event_type')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:85:            if (! Schema::hasColumn('webhook_subscriptions', 'app_installation_id')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:89:        Schema::table('webhook_deliveries', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:90:            if (! Schema::hasColumn('webhook_deliveries', 'subscription_id')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:93:            if (! Schema::hasColumn('webhook_deliveries', 'event_id')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:96:            if (! Schema::hasColumn('webhook_deliveries', 'attempt_count')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:99:            if (! Schema::hasColumn('webhook_deliveries', 'last_attempt_at')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:102:            if (! Schema::hasColumn('webhook_deliveries', 'response_code')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:105:            if (! Schema::hasColumn('webhook_deliveries', 'response_body_snippet')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
config/services.php:33:            'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
database/migrations/2026_08_20_220001_add_webhook_delivery_status.php:11:        Schema::table('webhook_deliveries', function (Blueprint $table): void {
database/migrations/2026_08_20_220001_add_webhook_delivery_status.php:18:        Schema::table('webhook_deliveries', function (Blueprint $table): void {
app/Jobs/DeliverWebhook.php:28:    public function handle(WebhookService $webhooks): void
app/Jobs/DeliverWebhook.php:34:            'X-Platform-Signature' => $webhooks->sign($payload, $subscription->secret_encrypted),
app/Providers/AppServiceProvider.php:50:        RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->getAuthIdentifier() ?? $request->ip()));
app/Providers/AppServiceProvider.php:54:        RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
app/Models/WebhookDelivery.php:10:    protected $fillable = ['webhook_subscription_id', 'subscription_id', 'event', 'event_id', 'payload', 'status', 'attempts', 'attempt_count', 'response_status', 'response_code', 'response_body', 'response_body_snippet', 'delivered_at', 'last_attempt_at', 'next_attempt_at'];
app/Models/WebhookDelivery.php:20:            $delivery->subscription_id ??= $delivery->webhook_subscription_id;
app/Models/WebhookDelivery.php:28:        return $this->belongsTo(WebhookSubscription::class, 'webhook_subscription_id');
<?php

use App\Http\Controllers\Api\AdminController;
use App\Http\Controllers\Api\StorefrontAnalyticsController;
use App\Http\Controllers\Api\StorefrontCartController;
use App\Http\Controllers\Api\StorefrontCheckoutController;
use App\Http\Controllers\Api\StorefrontOrderController;
use App\Http\Controllers\Api\StorefrontSearchController;
use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\Route;

Route::prefix('storefront/v1')->middleware([StartSession::class, 'store.resolve', 'throttle:api.storefront'])->group(function (): void {
    Route::post('carts', [StorefrontCartController::class, 'store']);
    Route::get('carts/{cartId}', [StorefrontCartController::class, 'show']);
    Route::post('carts/{cartId}/lines', [StorefrontCartController::class, 'addLine']);
    Route::put('carts/{cartId}/lines/{lineId}', [StorefrontCartController::class, 'updateLine']);
    Route::delete('carts/{cartId}/lines/{lineId}', [StorefrontCartController::class, 'removeLine']);
    Route::post('checkouts', [StorefrontCheckoutController::class, 'store'])->middleware('throttle:checkout');
    Route::get('checkouts/{checkoutId}', [StorefrontCheckoutController::class, 'show'])->middleware('throttle:checkout');
    Route::put('checkouts/{checkoutId}/address', [StorefrontCheckoutController::class, 'address'])->middleware('throttle:checkout');
    Route::put('checkouts/{checkoutId}/shipping-method', [StorefrontCheckoutController::class, 'shippingMethod'])->middleware('throttle:checkout');
    Route::put('checkouts/{checkoutId}/payment-method', [StorefrontCheckoutController::class, 'paymentMethod'])->middleware('throttle:checkout');
    Route::post('checkouts/{checkoutId}/apply-discount', [StorefrontCheckoutController::class, 'applyDiscount'])->middleware('throttle:checkout');
    Route::delete('checkouts/{checkoutId}/discount', [StorefrontCheckoutController::class, 'removeDiscount'])->middleware('throttle:checkout');
    Route::post('checkouts/{checkoutId}/pay', [StorefrontCheckoutController::class, 'pay'])->middleware('throttle:checkout');
    Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']);
    Route::get('search', [StorefrontSearchController::class, 'index'])->middleware('throttle:search');
    Route::get('search/suggest', [StorefrontSearchController::class, 'suggest'])->middleware('throttle:search');
    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics');
});

Route::prefix('admin/v1/stores/{storeId}')->middleware([StartSession::class, 'auth', 'store.resolve', 'role.check:owner,admin,staff,support', 'throttle:api.admin'])->group(function (): void {
    Route::get('products', [AdminController::class, 'products']);
    Route::post('products', [AdminController::class, 'storeProduct'])->middleware('role.check:owner,admin,staff');
    Route::get('products/{productId}', [AdminController::class, 'showProduct']);
    Route::put('products/{productId}', [AdminController::class, 'updateProduct'])->middleware('role.check:owner,admin,staff');
    Route::delete('products/{productId}', [AdminController::class, 'deleteProduct'])->middleware('role.check:owner,admin,staff');
    Route::get('collections', [AdminController::class, 'collections']);
    Route::post('collections', [AdminController::class, 'storeCollection'])->middleware('role.check:owner,admin,staff');
    Route::put('collections/{collectionId}', [AdminController::class, 'updateCollection'])->middleware('role.check:owner,admin,staff');
    Route::delete('collections/{collectionId}', [AdminController::class, 'deleteCollection'])->middleware('role.check:owner,admin,staff');
    Route::get('orders', [AdminController::class, 'orders']);
    Route::get('orders/{orderId}', [AdminController::class, 'showOrder']);
    Route::get('customers', [AdminController::class, 'customers']);
    Route::get('discounts', [AdminController::class, 'discounts']);
    Route::post('discounts', [AdminController::class, 'storeDiscount'])->middleware('role.check:owner,admin,staff');
    Route::put('discounts/{discountId}', [AdminController::class, 'updateDiscount'])->middleware('role.check:owner,admin,staff');
    Route::delete('discounts/{discountId}', [AdminController::class, 'deleteDiscount'])->middleware('role.check:owner,admin');
    Route::get('shipping/zones', [AdminController::class, 'shippingZones']);
    Route::post('shipping/zones', [AdminController::class, 'storeShippingZone'])->middleware('role.check:owner,admin');
    Route::put('shipping/zones/{zoneId}', [AdminController::class, 'updateShippingZone'])->middleware('role.check:owner,admin');
    Route::post('shipping/zones/{zoneId}/rates', [AdminController::class, 'storeShippingRate'])->middleware('role.check:owner,admin');
    Route::get('tax/settings', [AdminController::class, 'taxSettings']);
    Route::put('tax/settings', [AdminController::class, 'updateTaxSettings'])->middleware('role.check:owner,admin');
    Route::get('pages', [AdminController::class, 'pages']);
    Route::post('pages', [AdminController::class, 'storePage'])->middleware('role.check:owner,admin,staff');
    Route::put('pages/{pageId}', [AdminController::class, 'updatePage'])->middleware('role.check:owner,admin,staff');
    Route::delete('pages/{pageId}', [AdminController::class, 'deletePage'])->middleware('role.check:owner,admin');
    Route::post('themes', [AdminController::class, 'storeTheme'])->middleware('role.check:owner,admin');
    Route::post('themes/{themeId}/publish', [AdminController::class, 'publishTheme'])->middleware('role.check:owner,admin');
    Route::put('themes/{themeId}/settings', [AdminController::class, 'updateThemeSettings'])->middleware('role.check:owner,admin');
    Route::post('search/reindex', [AdminController::class, 'reindex'])->middleware('role.check:owner,admin,staff');
    Route::get('search/status', [AdminController::class, 'searchStatus']);
    Route::get('analytics/summary', [AdminController::class, 'analyticsSummary']);
    Route::post('orders/{orderId}/fulfillments', [AdminController::class, 'fulfillOrder'])->middleware('role.check:owner,admin,staff');
    Route::post('orders/{orderId}/refunds', [AdminController::class, 'refundOrder'])->middleware('role.check:owner,admin');
});
        $this->assertStore($storeId);
        $data = $request->validate(['mode' => ['sometimes', 'string'], 'provider' => ['sometimes', 'nullable', 'string'], 'prices_include_tax' => ['sometimes', 'boolean'], 'default_rate_basis_points' => ['sometimes', 'integer', 'min:0', 'max:10000'], 'rates_json' => ['sometimes', 'array'], 'provider_config_json' => ['sometimes', 'array']]);
        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);

        return response()->json(['data' => $settings]);
    }

    public function pages(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return $this->paginated(Page::withoutGlobalScopes()->where('store_id', $storeId)->latest()->paginate($request->integer('per_page', 25)));
    }

    public function storePage(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['title' => ['required', 'string', 'max:255'], 'handle' => ['nullable', 'string', 'max:255'], 'body_html' => ['nullable', 'string'], 'status' => ['required', 'in:draft,published']]);
        $page = Page::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'handle' => $data['handle'] ?? Str::slug($data['title']), 'published_at' => $data['status'] === 'published' ? now() : null]);

        return response()->json(['data' => $page], 201);
    }

    public function updatePage(Request $request, int $storeId, int $pageId): JsonResponse
    {
        $this->assertStore($storeId);
        $page = Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($pageId);
        $data = $request->validate(['title' => ['sometimes', 'string', 'max:255'], 'handle' => ['sometimes', 'string', 'max:255'], 'body_html' => ['sometimes', 'nullable', 'string'], 'status' => ['sometimes', 'in:draft,published']]);
        if (($data['status'] ?? null) === 'published') {
            $data['published_at'] = $page->published_at ?? now();
        }
        $page->update($data);

        return response()->json(['data' => $page->refresh()]);
    }

    public function deletePage(int $storeId, int $pageId): JsonResponse
    {
        $this->assertStore($storeId);
        Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($pageId)->delete();

        return response()->json(['message' => 'Page deleted.']);
    }

    public function storeTheme(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validate(['name' => ['required', 'string', 'max:255'], 'version' => ['nullable', 'string', 'max:30'], 'settings' => ['nullable', 'array']]);

        return response()->json(['data' => Theme::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => 'draft'])], 201);
    }

    public function publishTheme(int $storeId, int $themeId): JsonResponse
    {
        $this->assertStore($storeId);
        $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId);
        Theme::withoutGlobalScopes()->where('store_id', $storeId)->update(['status' => 'draft']);
        $theme->update(['status' => 'published']);

        return response()->json(['data' => $theme->refresh()]);
    }

    public function updateThemeSettings(Request $request, int $storeId, int $themeId): JsonResponse
    {
        $this->assertStore($storeId);
        $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId);
        $theme->update(['settings' => $request->validate(['settings' => ['required', 'array']])['settings']]);

        return response()->json(['data' => $theme->refresh()]);
    }

    public function reindex(SearchService $search, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        Product::withoutGlobalScopes()->where('store_id', $storeId)->each(fn (Product $product) => $search->syncProduct($product));

        return response()->json(['status' => 'completed']);
    }

    public function searchStatus(int $storeId): JsonResponse
    {
        $this->assertStore($storeId);

        return response()->json(['status' => 'ready', 'product_count' => Product::withoutGlobalScopes()->where('store_id', $storeId)->count()]);
    }

    public function analyticsSummary(Request $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $days = \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $storeId)->whereBetween('date', [$request->input('from', now()->subDays(29)->toDateString()), $request->input('to', now()->toDateString())])->get();

        return response()->json(['data' => ['visits' => (int) $days->sum('visits_count'), 'orders' => (int) $days->sum('orders_count'), 'revenue_amount' => (int) $days->sum('revenue_amount'), 'checkout_completed' => (int) $days->sum('checkout_completed_count'), 'days' => $days]]);
    }

    private function assertStore(int $storeId): void
    {
        abort_unless((int) app('current_store')->getKey() === $storeId, 404);
    }

    private function syncCollectionProducts(Collection $collection, array $productIds, int $storeId): void
    {
        $validIds = Product::withoutGlobalScopes()->where('store_id', $storeId)->whereIn('id', $productIds)->pluck('id')->all();
        $collection->products()->sync(array_fill_keys($validIds, ['position' => 0]));
    }

    private function paginated(LengthAwarePaginator $paginator): JsonResponse
    {
        return response()->json(['data' => $paginator->items(), 'meta' => ['current_page' => $paginator->currentPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), 'last_page' => $paginator->lastPage()]]);
    }
}