Script completed
Wall time 0.1 seconds
Output:

# API and Routing Specification

This document defines every route, endpoint, middleware stack, request/response schema, and rate limit for the e-commerce platform. The admin panel is built with Livewire v4 + Flux UI (server-rendered), while the storefront uses Blade + Livewire. REST API endpoints serve cart/checkout operations and programmatic admin access.

All monetary amounts are stored and transmitted in **minor units** (cents). All IDs are auto-incrementing integers. All monetary amounts are in minor units (cents).

---

## 1. Web Routes (Blade/Livewire Pages)

### 1.1 Admin Auth Routes

No middleware beyond the default `web` group. These routes handle admin login and logout.

| Method | Path | Description | Component / Action |
|--------|------|-------------|--------------------|
| GET | `/admin/login` | Admin login page | `Livewire: Admin\Auth\Login` |
| POST | `/admin/login` | Process admin login | Handled by Livewire action |
| POST | `/admin/logout` | Logout and invalidate session | Handled by Livewire action |
| GET | `/admin/forgot-password` | Show forgot password form | `Livewire: Admin\Auth\ForgotPassword` |
| POST | `/admin/forgot-password` | Send password reset link | Handled by Livewire action |
| GET | `/admin/reset-password/{token}` | Show reset password form | `Livewire: Admin\Auth\ResetPassword` |
| POST | `/admin/reset-password` | Reset password with token | Handled by Livewire action |

Rate limit: `login` tier (5 per minute per IP).

---

### 1.2 Admin Routes

**Prefix:** `/admin`
**Middleware:** `web`, `auth`, `verified`, `store.resolve`, `role.check`

All admin pages render Livewire full-page components. Data mutations happen via Livewire actions (not separate POST endpoints).

> **Note:** The Create and Edit routes render the same Livewire form component (`ProductForm`, `OrderForm`, etc.) in create vs edit mode, following the shared-form pattern defined in Spec 03.

#### Dashboard

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin` | Dashboard with KPI tiles, orders chart, conversion funnel | `Admin\Dashboard` |

#### Products

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |

#### Inventory

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/inventory` | Inventory management page | `Admin\Inventory\Index` |

#### Collections

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/collections` | Collection list | `Admin\Collections\Index` |
| GET | `/admin/collections/create` | Create collection form | `Admin\Collections\Create` |
| GET | `/admin/collections/{collection}/edit` | Edit collection form | `Admin\Collections\Edit` |

#### Orders

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/orders` | Order list with status filters | `Admin\Orders\Index` |
| GET | `/admin/orders/{order}` | Order detail with timeline, payments, fulfillments | `Admin\Orders\Show` |

#### Customers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/customers` | Customer list | `Admin\Customers\Index` |
| GET | `/admin/customers/{customer}` | Customer detail with order history, addresses | `Admin\Customers\Show` |

#### Discounts

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/discounts` | Discount list | `Admin\Discounts\Index` |
| GET | `/admin/discounts/create` | Create discount form with rules builder | `Admin\Discounts\Create` |
| GET | `/admin/discounts/{discount}/edit` | Edit discount form | `Admin\Discounts\Edit` |

#### Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/settings` | Store settings (tabs: General, Domains, Shipping, Taxes, Checkout, Notifications) | `Admin\Settings\Index` |
| GET | `/admin/settings/shipping` | Shipping zones and rates configuration | `Admin\Settings\Shipping` |
| GET | `/admin/settings/taxes` | Tax settings - mode, provider config, rates | `Admin\Settings\Taxes` |

#### Themes and Online Store

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/themes` | Theme management - cards with actions (Customize, Preview, Publish, Duplicate, Delete) | `Admin\Themes\Index` |
| GET | `/admin/themes/{theme}/editor` | Theme editor - left panel sections, center preview iframe, right panel settings | `Admin\Themes\Editor` |

#### Pages

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/pages` | Content pages list | `Admin\Pages\Index` |
| GET | `/admin/pages/create` | Create content page | `Admin\Pages\Create` |
| GET | `/admin/pages/{page}/edit` | Edit content page | `Admin\Pages\Edit` |

#### Navigation

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/navigation` | Navigation menus management | `Admin\Navigation\Index` |

#### Apps

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |

#### Developers

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |

> **Note:** Webhook subscription management (CRUD) is available exclusively through the admin UI at `/admin/developers`. There are no REST API endpoints for programmatic webhook subscription management in the initial implementation.

#### Analytics

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |

#### Search Settings

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/admin/search/settings` | Search settings - synonyms, stopwords, reindex | `Admin\Search\Settings` |

---

### 1.3 Storefront Routes

**Middleware:** `web`, `store.resolve`

These routes serve the public-facing storefront using Blade templates rendered through the active theme engine.

| Method | Path | Description | Component / View |
|--------|------|-------------|------------------|
| GET | `/` | Home page | `Storefront\Home` |
| GET | `/collections` | All collections listing | `Storefront\Collections\Index` |
| GET | `/collections/{handle}` | Single collection page with product grid and filters | `Storefront\Collections\Show` |
| GET | `/products/{handle}` | Product page - gallery, variant selector, add-to-cart | `Storefront\Products\Show` |
| GET | `/cart` | Full cart page - line items, quantity controls, discount input, totals | `Storefront\Cart\Show` |
| GET | `/search` | Search results page with filter panel, sort dropdown, product grid | `Storefront\Search\Index` |
| GET | `/pages/{handle}` | Static content page (About, FAQ, etc.) | `Storefront\Pages\Show` |

#### Customer Account Routes

**Additional middleware:** `auth:customer`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account` | Customer account overview | `Storefront\Account\Dashboard` |
| GET | `/account/orders` | Order history list | `Storefront\Account\Orders\Index` |
| GET | `/account/orders/{orderNumber}` | Order detail | `Storefront\Account\Orders\Show` |
| GET | `/account/addresses` | Address book management | `Storefront\Account\Addresses\Index` |

#### Customer Auth Routes (no auth required)

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/account/login` | Customer login page | `Storefront\Account\Auth\Login` |
| GET | `/account/register` | Customer registration page | `Storefront\Account\Auth\Register` |
| GET | `/forgot-password` | Forgot password page | `Storefront\Account\Auth\ForgotPassword` |
| POST | `/forgot-password` | Send reset link | Handled by Livewire action |
| GET | `/reset-password/{token}` | Reset password form | `Storefront\Account\Auth\ResetPassword` |
| POST | `/reset-password` | Reset password | Handled by Livewire action |

---

### 1.4 Checkout Routes

**Middleware:** `web`, `store.resolve`

| Method | Path | Description | Component |
|--------|------|-------------|-----------|
| GET | `/checkout/{checkoutId}` | Checkout page - stepper UI (Contact -> Shipping -> Payment) | `Storefront\Checkout\Show` |
| GET | `/checkout/{checkoutId}/confirmation` | Order confirmation page - order number, items, totals | `Storefront\Checkout\Confirmation` |

---

## 2. Storefront REST API

**Prefix:** `/api/storefront/v1`
**Middleware:** `store.resolve`, `throttle:api.storefront`
**Content-Type:** `application/json`

These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).

---

---

### 3.2 Products

#### Admin Products Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/products` | `read-products` | List products with filtering and pagination | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/products

List products with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title, vendor, or SKU. |
| `collection_id` | integer | No | Filter by collection membership. |
| `page` | integer | No | Page number. Default: 1. |
| `per_page` | integer | No | Results per page. Default: 25, max: 100. |
| `sort` | string | No | One of: `title_asc`, `title_desc`, `created_at_asc`, `created_at_desc`, `updated_at_desc`. Default: `updated_at_desc`. |

**Success response:** `200 OK`

```json
{
  "data": [
- `403` - Insufficient permissions
- `404` - Product not found
- `422` - Validation errors (invalid content type, file too large)

---

### 3.3 Collections

#### Admin Collections Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/collections` | `read-collections` | List collections | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/collections` | `write-collections` | Create a collection | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Update a collection | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/collections/{collectionId}` | `write-collections` | Delete a collection | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/collections

List collections.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter by status: `draft`, `active`, `archived`. |
| `query` | string | No | Search by title. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`
- `403` - Insufficient permissions
- `404` - Collection not found

---

### 3.4 Orders

#### Admin Orders Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/orders` | `read-orders` | List orders with filtering and pagination | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/orders/{orderId}` | `read-orders` | Get full order details | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/fulfillments` | `write-orders` | Create a fulfillment (ship items) | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/orders/{orderId}/refunds` | `write-orders` | Create a refund (partial or full) | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/orders

List orders with filtering and pagination.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `pending`, `paid`, `fulfilled`, `cancelled`, `refunded`. |
| `financial_status` | string | No | Filter: `pending`, `paid`, `partially_refunded`, `refunded`. |
| `fulfillment_status` | string | No | Filter: `unfulfilled`, `partial`, `fulfilled`. |
| `customer_id` | integer | No | Filter by customer. |
| `created_after` | string (ISO 8601) | No | Orders placed after this date. |
| `created_before` | string (ISO 8601) | No | Orders placed before this date. |
| `query` | string | No | Search by order number or customer email. |
- `502` - Payment provider refund failed

---

### 3.5 Discounts

#### Admin Discounts Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/discounts` | `read-discounts` | List discounts | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/discounts` | `write-discounts` | Create a discount | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Update a discount | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/discounts/{discountId}` | `write-discounts` | Delete a discount | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/discounts

List discounts.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `type` | string | No | Filter: `code`, `automatic`. |
| `status` | string | No | Filter: `active`, `expired`, `scheduled`. Computed from starts_at/ends_at. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

**Success response:** `200 OK`

```json
{
  "data": [
---

### 3.6 Shipping

#### Admin Shipping Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/shipping/zones` | `read-settings` | List shipping zones with rates | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones` | `write-settings` | Create a shipping zone | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}` | `write-settings` | Update a shipping zone | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/shipping/zones/{zoneId}/rates` | `write-settings` | Add a shipping rate to a zone | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/shipping/zones

List shipping zones with their rates.

**Success response:** `200 OK`

```json
{
  "data": [
    {
      "id": 1,
      "store_id": 1,
      "name": "Germany",
      "countries_json": ["DE"],
      "regions_json": [],
      "rates": [
        {
- `422` - Validation errors

---

### 3.7 Tax Settings

#### Admin Tax Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/tax/settings` | `read-settings` | Get tax configuration | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/tax/settings` | `write-settings` | Update tax configuration | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/tax/settings

Get tax configuration for a store.

**Success response:** `200 OK`

```json
{
  "data": {
    "store_id": 1,
    "mode": "manual",
    "provider": "none",
    "prices_include_tax": true,
---

### 3.8 Themes

#### Admin Themes Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/themes` | `write-themes` | Upload or install a new theme | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/themes/{themeId}/publish` | `write-themes` | Publish a theme (make active) | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/themes/{themeId}/settings` | `write-themes` | Update theme customization settings | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/themes

Upload or install a new theme.

**Request body (multipart/form-data):**

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `file` | file | Yes | ZIP archive. Max: 50MB. Must contain valid theme structure. |
| `name` | string | No | Max: 255. Defaults to theme manifest name. |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "store_id": 1,
    "name": "Dawn",

---

### 3.9 Pages

#### Admin Pages Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/pages` | `read-content` | List content pages | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/pages` | `write-content` | Create a content page | `api.admin` (60/min) |
| PUT | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Update a content page | `api.admin` (60/min) |
| DELETE | `/api/admin/v1/stores/{storeId}/pages/{pageId}` | `write-content` | Delete a content page | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/pages

List content pages.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `status` | string | No | Filter: `draft`, `published`. |
| `page` | integer | No | Default: 1. |
| `per_page` | integer | No | Default: 25, max: 100. |

---

### 3.10 Search Admin

#### Admin Search Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| POST | `/api/admin/v1/stores/{storeId}/search/reindex` | `write-settings` | Trigger full search reindex (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/search/status` | `read-settings` | Get current search index status | `api.admin` (60/min) |

#### POST /api/admin/v1/stores/{storeId}/search/reindex

Trigger a full search reindex for the store. Runs asynchronously via a queued job.

**Success response:** `202 Accepted`

```json
{
  "message": "Reindex job queued.",
```

---

### 3.11 Analytics and Exports

#### Admin Analytics Endpoint Summary

| Method | Path | Auth Scope | Description | Rate Limit |
|--------|------|------------|-------------|------------|
| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |

#### GET /api/admin/v1/stores/{storeId}/analytics/summary

Get analytics summary for a date range.

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `from` | string (YYYY-MM-DD) | Yes | Start date. |
| `to` | string (YYYY-MM-DD) | Yes | End date. Max range: 365 days. |
| `granularity` | string | No | One of: `day`, `week`, `month`. Default: `day`. |

**Success response:** `200 OK`

```json
{
  "data": {


exec

Input:
const patch = "*** Begin Patch\n*** Update File: /Users/fabianwesner/Herd/shop/app/Http/Middleware/ResolveStore.php\n@@\n-        $store = $request->is('admin', 'admin/*') ? $this->fromAdminSession($request) : $this->fromHostname($request);\n+        $adminApi = $request->is('api/admin/*');\n+        $store = $adminApi\n+            ? $this->fromAdminApiRoute($request)\n+            : ($request->is('admin', 'admin/*') ? $this->fromAdminSession($request) : $this->fromHostname($request));\n@@\n     private function fromAdminSession(Request $request): Store\n@@\n     }\n+\n+    private function fromAdminApiRoute(Request $request): Store\n+    {\n+        $user = $request->user();\n+        abort_if($user === null, 401);\n+        $storeId = (int) $request->route('storeId');\n+        abort_unless($storeId > 0 && $user->stores()->whereKey($storeId)->exists(), 403);\n+\n+        return Store::query()->findOrFail($storeId);\n+    }\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/CartController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Storefront;\n+\n+use App\\Exceptions\\CartVersionMismatchException;\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Cart;\n+use App\\Services\\CartService;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+\n+final class CartController extends Controller\n+{\n+    public function __construct(private readonly CartService $carts) {}\n+\n+    public function store(Request $request): JsonResponse\n+    {\n+        $store = app('current_store');\n+        $customer = auth('customer')->user();\n+        $cart = $this->carts->create($store, $customer);\n+        $request->session()->put('cart_id', $cart->id);\n+\n+        return response()->json(['data' => $this->data($cart)], 201);\n+    }\n+\n+    public function show(int $cartId): JsonResponse\n+    {\n+        return response()->json(['data' => $this->data($this->cart($cartId))]);\n+    }\n+\n+    public function addLine(Request $request, int $cartId): JsonResponse\n+    {\n+        $validated = $request->validate([\n+            'variant_id' => ['required', 'integer'],\n+            'quantity' => ['required', 'integer', 'min:1', 'max:99'],\n+            'expected_version' => ['sometimes', 'integer', 'min:1'],\n+        ]);\n+\n+        try {\n+            $cart = $this->cart($cartId);\n+            $this->carts->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['expected_version'] ?? null);\n+\n+            return response()->json(['data' => $this->data($cart->refresh())], 201);\n+        } catch (CartVersionMismatchException $exception) {\n+            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);\n+        }\n+    }\n+\n+    public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse\n+    {\n+        $validated = $request->validate([\n+            'quantity' => ['required', 'integer', 'min:0', 'max:99'],\n+            'expected_version' => ['sometimes', 'integer', 'min:1'],\n+        ]);\n+        try {\n+            $cart = $this->cart($cartId);\n+            $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? null);\n+\n+            return response()->json(['data' => $this->data($cart->refresh())]);\n+        } catch (CartVersionMismatchException $exception) {\n+            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);\n+        }\n+    }\n+\n+    public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse\n+    {\n+        $validated = $request->validate(['expected_version' => ['sometimes', 'integer', 'min:1']]);\n+        try {\n+            $cart = $this->cart($cartId);\n+            $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? null);\n+\n+            return response()->json(['data' => $this->data($cart->refresh())]);\n+        } catch (CartVersionMismatchException $exception) {\n+            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);\n+        }\n+    }\n+\n+    private function cart(int $id): Cart\n+    {\n+        return Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines.variant.product')->findOrFail($id);\n+    }\n+\n+    /** @return array<string, mixed> */\n+    private function data(Cart $cart): array\n+    {\n+        $cart->loadMissing('lines.variant.product');\n+\n+        return [\n+            'id' => $cart->id,\n+            'currency' => $cart->currency,\n+            'status' => $cart->status,\n+            'cart_version' => $cart->cart_version,\n+            'subtotal_amount' => (int) $cart->lines->sum('line_subtotal_amount'),\n+            'lines' => $cart->lines->map(fn ($line): array => [\n+                'id' => $line->id,\n+                'variant_id' => $line->variant_id,\n+                'title' => $line->variant?->product?->title,\n+                'quantity' => $line->quantity,\n+                'unit_price_amount' => $line->unit_price_amount,\n+                'line_subtotal_amount' => $line->line_subtotal_amount,\n+                'line_discount_amount' => $line->line_discount_amount,\n+                'line_total_amount' => $line->line_total_amount,\n+            ])->values(),\n+        ];\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/CheckoutController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Storefront;\n+\n+use App\\Exceptions\\PaymentFailedException;\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Cart;\n+use App\\Models\\Checkout;\n+use App\\Services\\CheckoutService;\n+use App\\Services\\ShippingCalculator;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+\n+final class CheckoutController extends Controller\n+{\n+    public function __construct(\n+        private readonly CheckoutService $checkouts,\n+        private readonly ShippingCalculator $shipping,\n+    ) {}\n+\n+    public function store(Request $request): JsonResponse\n+    {\n+        $validated = $request->validate(['cart_id' => ['required', 'integer']]);\n+        $cart = Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines')->findOrFail($validated['cart_id']);\n+        $checkout = $this->checkouts->create($cart);\n+\n+        return response()->json(['data' => $this->data($checkout)], 201);\n+    }\n+\n+    public function show(int $checkoutId): JsonResponse\n+    {\n+        return response()->json(['data' => $this->data($this->checkout($checkoutId))]);\n+    }\n+\n+    public function address(Request $request, int $checkoutId): JsonResponse\n+    {\n+        $validated = $request->validate([\n+            'email' => ['required', 'email'],\n+            'shipping_address' => ['required', 'array'],\n+            'shipping_address.first_name' => ['required', 'string'],\n+            'shipping_address.last_name' => ['required', 'string'],\n+            'shipping_address.address1' => ['required', 'string'],\n+            'shipping_address.city' => ['required', 'string'],\n+            'shipping_address.country' => ['required_without:shipping_address.country_code', 'nullable', 'string', 'size:2'],\n+            'shipping_address.country_code' => ['required_without:shipping_address.country', 'nullable', 'string', 'size:2'],\n+            'shipping_address.postal_code' => ['required_without:shipping_address.zip', 'nullable', 'string'],\n+            'shipping_address.zip' => ['required_without:shipping_address.postal_code', 'nullable', 'string'],\n+            'billing_address' => ['sometimes', 'array'],\n+        ]);\n+        $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $validated);\n+        $rates = $this->shipping->getAvailableRates(app('current_store'), (array) $checkout->shipping_address_json, $checkout->cart);\n+\n+        return response()->json(['data' => $this->data($checkout), 'available_shipping_rates' => $rates]);\n+    }\n+\n+    public function shipping(Request $request, int $checkoutId): JsonResponse\n+    {\n+        $validated = $request->validate(['shipping_rate_id' => ['nullable', 'integer']]);\n+        $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null);\n+\n+        return response()->json(['data' => $this->data($checkout)]);\n+    }\n+\n+    public function payment(Request $request, int $checkoutId): JsonResponse\n+    {\n+        $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);\n+        $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']);\n+\n+        return response()->json(['data' => $this->data($checkout)]);\n+    }\n+\n+    public function discount(Request $request, int $checkoutId): JsonResponse\n+    {\n+        $validated = $request->validate(['code' => ['required', 'string', 'max:100']]);\n+        $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']);\n+\n+        return response()->json(['data' => $this->data($checkout)]);\n+    }\n+\n+    public function removeDiscount(int $checkoutId): JsonResponse\n+    {\n+        $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), null);\n+\n+        return response()->json(['data' => $this->data($checkout)]);\n+    }\n+\n+    public function pay(Request $request, int $checkoutId): JsonResponse\n+    {\n+        $details = $request->validate([\n+            'card_number' => ['sometimes', 'string', 'max:30'],\n+            'expiry' => ['sometimes', 'string', 'max:10'],\n+            'cvc' => ['sometimes', 'string', 'max:4'],\n+            'cardholder_name' => ['sometimes', 'string', 'max:255'],\n+        ]);\n+        try {\n+            $order = $this->checkouts->completeCheckout($this->checkout($checkoutId), $details);\n+\n+            return response()->json(['data' => ['checkout' => $this->data($this->checkout($checkoutId)), 'order' => $order->load('lines.payments')]]);\n+        } catch (PaymentFailedException $exception) {\n+            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);\n+        }\n+    }\n+\n+    private function checkout(int $id): Checkout\n+    {\n+        return Checkout::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with(['cart.lines.variant', 'store'])->findOrFail($id);\n+    }\n+\n+    /** @return array<string, mixed> */\n+    private function data(Checkout $checkout): array\n+    {\n+        return [\n+            'id' => $checkout->id,\n+            'cart_id' => $checkout->cart_id,\n+            'status' => $checkout->status,\n+            'email' => $checkout->email,\n+            'shipping_address' => $checkout->shipping_address_json,\n+            'billing_address' => $checkout->billing_address_json,\n+            'shipping_method_id' => $checkout->shipping_method_id,\n+            'payment_method' => $checkout->payment_method,\n+            'discount_code' => $checkout->discount_code,\n+            'totals' => $checkout->totals_json,\n+            'expires_at' => $checkout->expires_at,\n+        ];\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/SearchController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Storefront;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Services\\SearchService;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+\n+final class SearchController extends Controller\n+{\n+    public function __construct(private readonly SearchService $search) {}\n+\n+    public function index(Request $request): JsonResponse\n+    {\n+        $validated = $request->validate(['q' => ['required', 'string', 'min:1', 'max:200'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50']]);\n+        $results = $this->search->search(app('current_store'), $validated['q'], $request->only(['vendor', 'collection_id']), $validated['per_page'] ?? 12);\n+\n+        return response()->json(['data' => $results->items(), 'meta' => ['current_page' => $results->currentPage(), 'last_page' => $results->lastPage(), 'total' => $results->total()]]);\n+    }\n+\n+    public function suggest(Request $request): JsonResponse\n+    {\n+        $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]);\n+\n+        return response()->json(['data' => $this->search->autocomplete(app('current_store'), $validated['q'])]);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/AnalyticsController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Storefront;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Services\\AnalyticsService;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+use Illuminate\\Support\\Carbon;\n+\n+final class AnalyticsController extends Controller\n+{\n+    public function __construct(private readonly AnalyticsService $analytics) {}\n+\n+    public function store(Request $request): JsonResponse\n+    {\n+        $validated = $request->validate([\n+            'events' => ['required', 'array', 'max:100'],\n+            'events.*.type' => ['required', 'in:page_view,product_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'],\n+            'events.*.session_id' => ['required', 'string', 'max:255'],\n+            'events.*.client_event_id' => ['required', 'string', 'max:255'],\n+            'events.*.occurred_at' => ['required', 'date'],\n+            'events.*.properties' => ['sometimes', 'array'],\n+        ]);\n+        foreach ($validated['events'] as $event) {\n+            $this->analytics->track(\n+                app('current_store'),\n+                $event['type'],\n+                $event['properties'] ?? [],\n+                $event['session_id'],\n+                auth('customer')->id(),\n+                $event['client_event_id'],\n+                Carbon::parse($event['occurred_at']),\n+            );\n+        }\n+\n+        return response()->json(['accepted' => count($validated['events'])], 202);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/routes/api.php\n+<?php\n+\n+use App\\Http\\Controllers\\Api\\Storefront\\AnalyticsController;\n+use App\\Http\\Controllers\\Api\\Storefront\\CartController;\n+use App\\Http\\Controllers\\Api\\Storefront\\CheckoutController;\n+use App\\Http\\Controllers\\Api\\Storefront\\SearchController;\n+use Illuminate\\Support\\Facades\\Route;\n+\n+Route::prefix('storefront/v1')->middleware(['store.resolve', 'throttle:api.storefront'])->group(function (): void {\n+    Route::post('carts', [CartController::class, 'store']);\n+    Route::get('carts/{cartId}', [CartController::class, 'show']);\n+    Route::post('carts/{cartId}/lines', [CartController::class, 'addLine']);\n+    Route::put('carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']);\n+    Route::delete('carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine']);\n+\n+    Route::middleware('throttle:checkout')->group(function (): void {\n+        Route::post('checkouts', [CheckoutController::class, 'store']);\n+        Route::get('checkouts/{checkoutId}', [CheckoutController::class, 'show']);\n+        Route::put('checkouts/{checkoutId}/address', [CheckoutController::class, 'address']);\n+        Route::put('checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'shipping']);\n+        Route::put('checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'payment']);\n+        Route::post('checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'discount']);\n+        Route::delete('checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']);\n+        Route::post('checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay']);\n+    });\n+\n+    Route::get('search', [SearchController::class, 'index'])->middleware('throttle:search');\n+    Route::get('search/suggest', [SearchController::class, 'suggest'])->middleware('throttle:search');\n+    Route::post('analytics/events', [AnalyticsController::class, 'store'])->middleware('throttle:analytics');\n+});\n*** End Patch";
text(await tools.apply_patch(patch));


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/CartController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/CheckoutController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/SearchController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Storefront/AnalyticsController.php
A /Users/fabianwesner/Herd/shop/routes/api.php
M /Users/fabianwesner/Herd/shop/app/Http/Middleware/ResolveStore.php

<?php

namespace App\Http\Controllers\Api\Storefront;

use App\Http\Controllers\Controller;
use App\Services\AnalyticsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;

final class AnalyticsController extends Controller
{
    public function __construct(private readonly AnalyticsService $analytics) {}

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'events' => ['required', 'array', 'max:100'],
            'events.*.type' => ['required', 'in:page_view,product_view,add_to_cart,remove_from_cart,checkout_started,checkout_completed,search'],
            'events.*.session_id' => ['required', 'string', 'max:255'],
            'events.*.client_event_id' => ['required', 'string', 'max:255'],
            'events.*.occurred_at' => ['required', 'date'],
            'events.*.properties' => ['sometimes', 'array'],
        ]);
        foreach ($validated['events'] as $event) {
            $this->analytics->track(
                app('current_store'),
                $event['type'],
                $event['properties'] ?? [],
                $event['session_id'],
                auth('customer')->id(),
                $event['client_event_id'],
                Carbon::parse($event['occurred_at']),
            );
        }

        return response()->json(['accepted' => count($validated['events'])], 202);
    }
}

<?php

namespace App\Http\Controllers\Api\Storefront;

use App\Exceptions\PaymentFailedException;
use App\Http\Controllers\Controller;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\CheckoutService;
use App\Services\ShippingCalculator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class CheckoutController extends Controller
{
    public function __construct(
        private readonly CheckoutService $checkouts,
        private readonly ShippingCalculator $shipping,
    ) {}

    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate(['cart_id' => ['required', 'integer']]);
        $cart = Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines')->findOrFail($validated['cart_id']);
        $checkout = $this->checkouts->create($cart);

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

    public function show(int $checkoutId): JsonResponse
    {
        return response()->json(['data' => $this->data($this->checkout($checkoutId))]);
    }

    public function address(Request $request, int $checkoutId): JsonResponse
    {
        $validated = $request->validate([
            'email' => ['required', 'email'],
            'shipping_address' => ['required', 'array'],
            'shipping_address.first_name' => ['required', 'string'],
            'shipping_address.last_name' => ['required', 'string'],
            'shipping_address.address1' => ['required', 'string'],
            'shipping_address.city' => ['required', 'string'],
            'shipping_address.country' => ['required_without:shipping_address.country_code', 'nullable', 'string', 'size:2'],
            'shipping_address.country_code' => ['required_without:shipping_address.country', 'nullable', 'string', 'size:2'],
            'shipping_address.postal_code' => ['required_without:shipping_address.zip', 'nullable', 'string'],
            'shipping_address.zip' => ['required_without:shipping_address.postal_code', 'nullable', 'string'],
            'billing_address' => ['sometimes', 'array'],
        ]);
        $checkout = $this->checkouts->setAddress($this->checkout($checkoutId), $validated);
        $rates = $this->shipping->getAvailableRates(app('current_store'), (array) $checkout->shipping_address_json, $checkout->cart);

        return response()->json(['data' => $this->data($checkout), 'available_shipping_rates' => $rates]);
    }

    public function shipping(Request $request, int $checkoutId): JsonResponse
    {
        $validated = $request->validate(['shipping_rate_id' => ['nullable', 'integer']]);
        $checkout = $this->checkouts->setShippingMethod($this->checkout($checkoutId), $validated['shipping_rate_id'] ?? null);

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

    public function payment(Request $request, int $checkoutId): JsonResponse
    {
        $validated = $request->validate(['payment_method' => ['required', 'in:credit_card,paypal,bank_transfer']]);
        $checkout = $this->checkouts->selectPaymentMethod($this->checkout($checkoutId), $validated['payment_method']);

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

    public function discount(Request $request, int $checkoutId): JsonResponse
    {
        $validated = $request->validate(['code' => ['required', 'string', 'max:100']]);
        $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), $validated['code']);

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

    public function removeDiscount(int $checkoutId): JsonResponse
    {
        $checkout = $this->checkouts->applyDiscount($this->checkout($checkoutId), null);

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

    public function pay(Request $request, int $checkoutId): JsonResponse
    {
        $details = $request->validate([
            'card_number' => ['sometimes', 'string', 'max:30'],
            'expiry' => ['sometimes', 'string', 'max:10'],
            'cvc' => ['sometimes', 'string', 'max:4'],
            'cardholder_name' => ['sometimes', 'string', 'max:255'],
        ]);
        try {
            $order = $this->checkouts->completeCheckout($this->checkout($checkoutId), $details);

            return response()->json(['data' => ['checkout' => $this->data($this->checkout($checkoutId)), 'order' => $order->load('lines.payments')]]);
        } catch (PaymentFailedException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'error_code' => $exception->errorCode], 422);
        }
    }

    private function checkout(int $id): Checkout
    {
        return Checkout::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with(['cart.lines.variant', 'store'])->findOrFail($id);
    }

    /** @return array<string, mixed> */
    private function data(Checkout $checkout): array
    {
        return [
            'id' => $checkout->id,
            'cart_id' => $checkout->cart_id,
            'status' => $checkout->status,
            'email' => $checkout->email,
            'shipping_address' => $checkout->shipping_address_json,
            'billing_address' => $checkout->billing_address_json,
            'shipping_method_id' => $checkout->shipping_method_id,
            'payment_method' => $checkout->payment_method,
            'discount_code' => $checkout->discount_code,
            'totals' => $checkout->totals_json,
            'expires_at' => $checkout->expires_at,
        ];
    }
}

<?php

namespace App\Http\Controllers\Api\Storefront;

use App\Http\Controllers\Controller;
use App\Services\SearchService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class SearchController extends Controller
{
    public function __construct(private readonly SearchService $search) {}

    public function index(Request $request): JsonResponse
    {
        $validated = $request->validate(['q' => ['required', 'string', 'min:1', 'max:200'], 'per_page' => ['sometimes', 'integer', 'min:1', 'max:50']]);
        $results = $this->search->search(app('current_store'), $validated['q'], $request->only(['vendor', 'collection_id']), $validated['per_page'] ?? 12);

        return response()->json(['data' => $results->items(), 'meta' => ['current_page' => $results->currentPage(), 'last_page' => $results->lastPage(), 'total' => $results->total()]]);
    }

    public function suggest(Request $request): JsonResponse
    {
        $validated = $request->validate(['q' => ['required', 'string', 'min:2', 'max:100']]);

        return response()->json(['data' => $this->search->autocomplete(app('current_store'), $validated['q'])]);
    }
}

@@ -17,3 +17,6 @@
     {
-        $store = $request->is('admin', 'admin/*') ? $this->fromAdminSession($request) : $this->fromHostname($request);
+        $adminApi = $request->is('api/admin/*');
+        $store = $adminApi
+            ? $this->fromAdminApiRoute($request)
+            : ($request->is('admin', 'admin/*') ? $this->fromAdminSession($request) : $this->fromHostname($request));
         app()->instance('current_store', $store);
@@ -58,2 +61,12 @@

+    private function fromAdminApiRoute(Request $request): Store
+    {
+        $user = $request->user();
+        abort_if($user === null, 401);
+        $storeId = (int) $request->route('storeId');
+        abort_unless($storeId > 0 && $user->stores()->whereKey($storeId)->exists(), 403);
+
+        return Store::query()->findOrFail($storeId);
+    }
+
     private function status(Store $store): string

<?php

use App\Http\Controllers\Api\Storefront\AnalyticsController;
use App\Http\Controllers\Api\Storefront\CartController;
use App\Http\Controllers\Api\Storefront\CheckoutController;
use App\Http\Controllers\Api\Storefront\SearchController;
use Illuminate\Support\Facades\Route;

Route::prefix('storefront/v1')->middleware(['store.resolve', 'throttle:api.storefront'])->group(function (): void {
    Route::post('carts', [CartController::class, 'store']);
    Route::get('carts/{cartId}', [CartController::class, 'show']);
    Route::post('carts/{cartId}/lines', [CartController::class, 'addLine']);
    Route::put('carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']);
    Route::delete('carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine']);

    Route::middleware('throttle:checkout')->group(function (): void {
        Route::post('checkouts', [CheckoutController::class, 'store']);
        Route::get('checkouts/{checkoutId}', [CheckoutController::class, 'show']);
        Route::put('checkouts/{checkoutId}/address', [CheckoutController::class, 'address']);
        Route::put('checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'shipping']);
        Route::put('checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'payment']);
        Route::post('checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'discount']);
        Route::delete('checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']);
        Route::post('checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay']);
    });

    Route::get('search', [SearchController::class, 'index'])->middleware('throttle:search');
    Route::get('search/suggest', [SearchController::class, 'suggest'])->middleware('throttle:search');
    Route::post('analytics/events', [AnalyticsController::class, 'store'])->middleware('throttle:analytics');
});

<?php

namespace App\Http\Controllers\Api\Storefront;

use App\Exceptions\CartVersionMismatchException;
use App\Http\Controllers\Controller;
use App\Models\Cart;
use App\Services\CartService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class CartController extends Controller
{
    public function __construct(private readonly CartService $carts) {}

    public function store(Request $request): JsonResponse
    {
        $store = app('current_store');
        $customer = auth('customer')->user();
        $cart = $this->carts->create($store, $customer);
        $request->session()->put('cart_id', $cart->id);

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

    public function show(int $cartId): JsonResponse
    {
        return response()->json(['data' => $this->data($this->cart($cartId))]);
    }

    public function addLine(Request $request, int $cartId): JsonResponse
    {
        $validated = $request->validate([
            'variant_id' => ['required', 'integer'],
            'quantity' => ['required', 'integer', 'min:1', 'max:99'],
            'expected_version' => ['sometimes', 'integer', 'min:1'],
        ]);

        try {
            $cart = $this->cart($cartId);
            $this->carts->addLine($cart, $validated['variant_id'], $validated['quantity'], $validated['expected_version'] ?? null);

            return response()->json(['data' => $this->data($cart->refresh())], 201);
        } catch (CartVersionMismatchException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);
        }
    }

    public function updateLine(Request $request, int $cartId, int $lineId): JsonResponse
    {
        $validated = $request->validate([
            'quantity' => ['required', 'integer', 'min:0', 'max:99'],
            'expected_version' => ['sometimes', 'integer', 'min:1'],
        ]);
        try {
            $cart = $this->cart($cartId);
            $this->carts->updateLineQuantity($cart, $lineId, $validated['quantity'], $validated['expected_version'] ?? null);

            return response()->json(['data' => $this->data($cart->refresh())]);
        } catch (CartVersionMismatchException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);
        }
    }

    public function removeLine(Request $request, int $cartId, int $lineId): JsonResponse
    {
        $validated = $request->validate(['expected_version' => ['sometimes', 'integer', 'min:1']]);
        try {
            $cart = $this->cart($cartId);
            $this->carts->removeLine($cart, $lineId, $validated['expected_version'] ?? null);

            return response()->json(['data' => $this->data($cart->refresh())]);
        } catch (CartVersionMismatchException $exception) {
            return response()->json(['message' => $exception->getMessage(), 'data' => $this->data($exception->cart)], 409);
        }
    }

    private function cart(int $id): Cart
    {
        return Cart::withoutGlobalScopes()->where('store_id', app('current_store')->id)->with('lines.variant.product')->findOrFail($id);
    }

    /** @return array<string, mixed> */
    private function data(Cart $cart): array
    {
        $cart->loadMissing('lines.variant.product');

        return [
            'id' => $cart->id,
            'currency' => $cart->currency,
            'status' => $cart->status,
            'cart_version' => $cart->cart_version,
            'subtotal_amount' => (int) $cart->lines->sum('line_subtotal_amount'),
            'lines' => $cart->lines->map(fn ($line): array => [
                'id' => $line->id,
                'variant_id' => $line->variant_id,
                'title' => $line->variant?->product?->title,
                'quantity' => $line->quantity,
                'unit_price_amount' => $line->unit_price_amount,
                'line_subtotal_amount' => $line->line_subtotal_amount,
                'line_discount_amount' => $line->line_discount_amount,
                'line_total_amount' => $line->line_total_amount,
            ])->values(),
        ];
    }
}


Script completed
Wall time 1.0 seconds
Output:


Input:
const patch = "*** Begin Patch\n*** Update File: /Users/fabianwesner/Herd/shop/bootstrap/app.php\n@@\n use App\\Http\\Middleware\\ResolveStore;\n+use Laravel\\Sanctum\\Http\\Middleware\\CheckAbilities;\n@@\n             'customer.auth' => CustomerAuthenticate::class,\n+            'abilities' => CheckAbilities::class,\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/ProductController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Admin;\n+\n+use App\\Enums\\ProductStatus;\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Product;\n+use App\\Services\\ProductService;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+use Illuminate\\Validation\\Rule;\n+\n+final class ProductController extends Controller\n+{\n+    public function __construct(private readonly ProductService $products) {}\n+\n+    public function index(Request $request, int $storeId): JsonResponse\n+    {\n+        $validated = $request->validate([\n+            'status' => ['sometimes', 'in:draft,active,archived'],\n+            'query' => ['sometimes', 'string', 'max:200'],\n+            'collection_id' => ['sometimes', 'integer'],\n+            'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],\n+            'sort' => ['sometimes', 'in:title_asc,title_desc,created_at_asc,created_at_desc,updated_at_desc'],\n+        ]);\n+        [$sort, $direction] = match ($validated['sort'] ?? 'updated_at_desc') {\n+            'title_asc' => ['title', 'asc'], 'title_desc' => ['title', 'desc'],\n+            'created_at_asc' => ['created_at', 'asc'], 'created_at_desc' => ['created_at', 'desc'],\n+            default => ['updated_at', 'desc'],\n+        };\n+        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)\n+            ->when(isset($validated['status']), fn ($query) => $query->where('status', $validated['status']))\n+            ->when(isset($validated['query']), fn ($query) => $query->where(function ($nested) use ($validated): void {\n+                $term = $validated['query'];\n+                $nested->where('title', 'like', \"%{$term}%\")->orWhere('vendor', 'like', \"%{$term}%\")\n+                    ->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', \"%{$term}%\"));\n+            }))\n+            ->when(isset($validated['collection_id']), fn ($query) => $query->whereHas('collections', fn ($collections) => $collections->whereKey($validated['collection_id'])))\n+            ->with(['variants.inventoryItem', 'media', 'collections'])->orderBy($sort, $direction)->paginate($validated['per_page'] ?? 25);\n+\n+        return response()->json(['data' => $products->items(), 'meta' => ['current_page' => $products->currentPage(), 'last_page' => $products->lastPage(), 'total' => $products->total()]]);\n+    }\n+\n+    public function store(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $this->validated($request, $storeId);\n+        $product = $this->products->create(app('current_store'), $data);\n+\n+        return response()->json(['data' => $product], 201);\n+    }\n+\n+    public function show(int $storeId, int $productId): JsonResponse\n+    {\n+        return response()->json(['data' => $this->product($storeId, $productId)->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections'])]);\n+    }\n+\n+    public function update(Request $request, int $storeId, int $productId): JsonResponse\n+    {\n+        $data = $this->validated($request, $storeId, true);\n+\n+        return response()->json(['data' => $this->products->update($this->product($storeId, $productId), $data)]);\n+    }\n+\n+    public function destroy(int $storeId, int $productId): JsonResponse\n+    {\n+        $product = $this->product($storeId, $productId);\n+        $this->products->transitionStatus($product, ProductStatus::Archived);\n+\n+        return response()->json(['message' => 'Product archived.', 'data' => $product->refresh()]);\n+    }\n+\n+    /** @return array<string, mixed> */\n+    private function validated(Request $request, int $storeId, bool $partial = false): array\n+    {\n+        $sometimes = $partial ? ['sometimes'] : ['required'];\n+\n+        return $request->validate([\n+            'title' => [...$sometimes, 'string', 'max:255'],\n+            'handle' => ['sometimes', 'string', 'max:255', Rule::unique('products')->where('store_id', $storeId)->ignore($request->route('productId'))],\n+            'description_html' => ['nullable', 'string'],\n+            'vendor' => ['nullable', 'string', 'max:255'],\n+            'product_type' => ['nullable', 'string', 'max:255'],\n+            'tags' => ['sometimes', 'array'],\n+            'status' => ['sometimes', 'in:draft,active,archived'],\n+            'options' => ['sometimes', 'array', 'max:3'],\n+            'options.*.name' => ['required_with:options', 'string', 'max:100'],\n+            'options.*.values' => ['required_with:options', 'array', 'min:1'],\n+            'variant' => ['sometimes', 'array'],\n+            'variant.price_amount' => ['sometimes', 'integer', 'min:0'],\n+            'variant.sku' => ['nullable', 'string', 'max:100'],\n+            'variant.quantity_on_hand' => ['sometimes', 'integer'],\n+        ]);\n+    }\n+\n+    private function product(int $storeId, int $productId): Product\n+    {\n+        return Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/CollectionController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Admin;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Collection;\n+use App\\Support\\HandleGenerator;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+use Illuminate\\Validation\\Rule;\n+\n+final class CollectionController extends Controller\n+{\n+    public function __construct(private readonly HandleGenerator $handles) {}\n+\n+    public function index(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $request->validate(['status' => ['sometimes', 'in:draft,active,archived'], 'query' => ['sometimes', 'string'], 'per_page' => ['sometimes', 'integer', 'max:100']]);\n+        $items = Collection::withoutGlobalScopes()->where('store_id', $storeId)\n+            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))\n+            ->when(isset($data['query']), fn ($q) => $q->where('title', 'like', '%'.$data['query'].'%'))\n+            ->withCount('products')->paginate($data['per_page'] ?? 25);\n+\n+        return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total(), 'current_page' => $items->currentPage()]]);\n+    }\n+\n+    public function store(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $this->validateData($request, $storeId);\n+        $collection = Collection::withoutGlobalScopes()->create([\n+            ...$data,\n+            'store_id' => $storeId,\n+            'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'collections', $storeId),\n+        ]);\n+        $collection->products()->sync($this->positions($data['product_ids'] ?? []));\n+\n+        return response()->json(['data' => $collection->load('products')], 201);\n+    }\n+\n+    public function update(Request $request, int $storeId, int $collectionId): JsonResponse\n+    {\n+        $collection = $this->find($storeId, $collectionId);\n+        $data = $this->validateData($request, $storeId, $collectionId, true);\n+        $collection->update(collect($data)->except('product_ids')->all());\n+        if (array_key_exists('product_ids', $data)) {\n+            $collection->products()->sync($this->positions($data['product_ids']));\n+        }\n+\n+        return response()->json(['data' => $collection->refresh()->load('products')]);\n+    }\n+\n+    public function destroy(int $storeId, int $collectionId): JsonResponse\n+    {\n+        $this->find($storeId, $collectionId)->delete();\n+\n+        return response()->json(['message' => 'Collection deleted.']);\n+    }\n+\n+    /** @return array<string, mixed> */\n+    private function validateData(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array\n+    {\n+        return $request->validate([\n+            'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'],\n+            'handle' => ['sometimes', 'string', Rule::unique('collections')->where('store_id', $storeId)->ignore($ignore)],\n+            'description_html' => ['nullable', 'string'],\n+            'type' => ['sometimes', 'in:manual,automated'],\n+            'status' => ['sometimes', 'in:draft,active,archived'],\n+            'product_ids' => ['sometimes', 'array'],\n+            'product_ids.*' => ['integer'],\n+        ]);\n+    }\n+\n+    /** @param list<int> $ids @return array<int, array{position: int}> */\n+    private function positions(array $ids): array\n+    {\n+        return collect(array_values($ids))->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])->all();\n+    }\n+\n+    private function find(int $storeId, int $id): Collection\n+    {\n+        return Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/OrderController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Admin;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Order;\n+use App\\Services\\FulfillmentService;\n+use App\\Services\\OrderService;\n+use App\\Services\\RefundService;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+\n+final class OrderController extends Controller\n+{\n+    public function __construct(\n+        private readonly FulfillmentService $fulfillments,\n+        private readonly RefundService $refunds,\n+        private readonly OrderService $orders,\n+    ) {}\n+\n+    public function index(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $request->validate([\n+            'status' => ['sometimes', 'in:pending,paid,fulfilled,cancelled,refunded'],\n+            'financial_status' => ['sometimes', 'in:pending,authorized,paid,partially_refunded,refunded,voided'],\n+            'fulfillment_status' => ['sometimes', 'in:unfulfilled,partial,fulfilled'],\n+            'query' => ['sometimes', 'string'],\n+            'per_page' => ['sometimes', 'integer', 'max:100'],\n+        ]);\n+        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)\n+            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))\n+            ->when(isset($data['financial_status']), fn ($q) => $q->where('financial_status', $data['financial_status']))\n+            ->when(isset($data['fulfillment_status']), fn ($q) => $q->where('fulfillment_status', $data['fulfillment_status']))\n+            ->when(isset($data['query']), fn ($q) => $q->where(fn ($nested) => $nested->where('order_number', 'like', '%'.$data['query'].'%')->orWhere('email', 'like', '%'.$data['query'].'%')))\n+            ->with('customer')->latest('placed_at')->paginate($data['per_page'] ?? 25);\n+\n+        return response()->json(['data' => $orders->items(), 'meta' => ['total' => $orders->total(), 'current_page' => $orders->currentPage()]]);\n+    }\n+\n+    public function show(int $storeId, int $orderId): JsonResponse\n+    {\n+        return response()->json(['data' => $this->find($storeId, $orderId)->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])]);\n+    }\n+\n+    public function fulfill(Request $request, int $storeId, int $orderId): JsonResponse\n+    {\n+        $data = $request->validate([\n+            'lines' => ['required', 'array', 'min:1'],\n+            'lines.*' => ['required', 'integer', 'min:1'],\n+            'tracking_company' => ['nullable', 'string'],\n+            'tracking_number' => ['nullable', 'string'],\n+            'tracking_url' => ['nullable', 'url'],\n+        ]);\n+        $tracking = collect($data)->only(['tracking_company', 'tracking_number', 'tracking_url'])->all();\n+        $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $data['lines'], $tracking);\n+\n+        return response()->json(['data' => $fulfillment], 201);\n+    }\n+\n+    public function refund(Request $request, int $storeId, int $orderId): JsonResponse\n+    {\n+        $data = $request->validate(['amount' => ['required', 'integer', 'min:1'], 'reason' => ['nullable', 'string'], 'restock' => ['sometimes', 'boolean'], 'lines' => ['sometimes', 'array']]);\n+        $order = $this->find($storeId, $orderId)->load('payments');\n+        $refund = $this->refunds->create($order, $order->payments->firstOrFail(), $data['amount'], $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? null);\n+\n+        return response()->json(['data' => $refund], 201);\n+    }\n+\n+    public function confirmPayment(int $storeId, int $orderId): JsonResponse\n+    {\n+        $order = $this->find($storeId, $orderId);\n+        $this->orders->confirmBankTransfer($order);\n+\n+        return response()->json(['data' => $order->refresh()]);\n+    }\n+\n+    private function find(int $storeId, int $id): Order\n+    {\n+        return Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/DiscountController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Admin;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\Discount;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+use Illuminate\\Validation\\Rule;\n+\n+final class DiscountController extends Controller\n+{\n+    public function index(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $request->validate(['type' => ['sometimes', 'in:code,automatic'], 'status' => ['sometimes', 'in:draft,active,expired,disabled'], 'per_page' => ['sometimes', 'integer', 'max:100']]);\n+        $items = Discount::withoutGlobalScopes()->where('store_id', $storeId)\n+            ->when(isset($data['type']), fn ($q) => $q->where('type', $data['type']))\n+            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))\n+            ->latest()->paginate($data['per_page'] ?? 25);\n+\n+        return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total()]]);\n+    }\n+\n+    public function store(Request $request, int $storeId): JsonResponse\n+    {\n+        $discount = Discount::withoutGlobalScopes()->create(['store_id' => $storeId, ...$this->data($request, $storeId)]);\n+\n+        return response()->json(['data' => $discount], 201);\n+    }\n+\n+    public function update(Request $request, int $storeId, int $discountId): JsonResponse\n+    {\n+        $discount = $this->find($storeId, $discountId);\n+        $discount->update($this->data($request, $storeId, $discountId, true));\n+\n+        return response()->json(['data' => $discount->refresh()]);\n+    }\n+\n+    public function destroy(int $storeId, int $discountId): JsonResponse\n+    {\n+        $this->find($storeId, $discountId)->delete();\n+\n+        return response()->json(['message' => 'Discount deleted.']);\n+    }\n+\n+    /** @return array<string, mixed> */\n+    private function data(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array\n+    {\n+        return $request->validate([\n+            'type' => [$partial ? 'sometimes' : 'required', 'in:code,automatic'],\n+            'code' => ['nullable', 'string', Rule::unique('discounts')->where('store_id', $storeId)->ignore($ignore)],\n+            'value_type' => [$partial ? 'sometimes' : 'required', 'in:fixed,percent,free_shipping'],\n+            'value_amount' => [$partial ? 'sometimes' : 'required', 'integer', 'min:0'],\n+            'starts_at' => [$partial ? 'sometimes' : 'required', 'date'],\n+            'ends_at' => ['nullable', 'date', 'after:starts_at'],\n+            'usage_limit' => ['nullable', 'integer', 'min:1'],\n+            'rules_json' => ['sometimes', 'array'],\n+            'status' => ['sometimes', 'in:draft,active,expired,disabled'],\n+        ]);\n+    }\n+\n+    private function find(int $storeId, int $id): Discount\n+    {\n+        return Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);\n+    }\n+}\n*** Add File: /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/SettingsController.php\n+<?php\n+\n+namespace App\\Http\\Controllers\\Api\\Admin;\n+\n+use App\\Http\\Controllers\\Controller;\n+use App\\Models\\ShippingZone;\n+use App\\Models\\TaxSettings;\n+use Illuminate\\Http\\JsonResponse;\n+use Illuminate\\Http\\Request;\n+\n+final class SettingsController extends Controller\n+{\n+    public function zones(int $storeId): JsonResponse\n+    {\n+        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);\n+    }\n+\n+    public function storeZone(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $request->validate(['name' => ['required', 'string'], 'countries_json' => ['required', 'array'], 'regions_json' => ['sometimes', 'array']]);\n+        $zone = ShippingZone::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'regions_json' => $data['regions_json'] ?? []]);\n+\n+        return response()->json(['data' => $zone], 201);\n+    }\n+\n+    public function updateZone(Request $request, int $storeId, int $zoneId): JsonResponse\n+    {\n+        $zone = $this->zone($storeId, $zoneId);\n+        $zone->update($request->validate(['name' => ['sometimes', 'string'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']]));\n+\n+        return response()->json(['data' => $zone->refresh()]);\n+    }\n+\n+    public function storeRate(Request $request, int $storeId, int $zoneId): JsonResponse\n+    {\n+        $data = $request->validate(['name' => ['required', 'string'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'config_json' => ['required', 'array'], 'is_active' => ['sometimes', 'boolean']]);\n+        $rate = $this->zone($storeId, $zoneId)->rates()->create($data);\n+\n+        return response()->json(['data' => $rate], 201);\n+    }\n+\n+    public function tax(int $storeId): JsonResponse\n+    {\n+        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->where('store_id', $storeId)->firstOrFail()]);\n+    }\n+\n+    public function updateTax(Request $request, int $storeId): JsonResponse\n+    {\n+        $data = $request->validate(['mode' => ['required', 'in:manual,provider'], 'provider' => ['required', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array']]);\n+        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);\n+\n+        return response()->json(['data' => $settings]);\n+    }\n+\n+    private function zone(int $storeId, int $id): ShippingZone\n+    {\n+        return ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);\n+    }\n+}\n*** End Patch";
text(await tools.apply_patch(patch));


Success. Updated the following files:
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/ProductController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/CollectionController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/OrderController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/DiscountController.php
A /Users/fabianwesner/Herd/shop/app/Http/Controllers/Api/Admin/SettingsController.php
M /Users/fabianwesner/Herd/shop/bootstrap/app.php

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class SettingsController extends Controller
{
    public function zones(int $storeId): JsonResponse
    {
        return response()->json(['data' => ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->with('rates')->get()]);
    }

    public function storeZone(Request $request, int $storeId): JsonResponse
    {
        $data = $request->validate(['name' => ['required', 'string'], 'countries_json' => ['required', 'array'], 'regions_json' => ['sometimes', 'array']]);
        $zone = ShippingZone::withoutGlobalScopes()->create(['store_id' => $storeId, ...$data, 'regions_json' => $data['regions_json'] ?? []]);

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

    public function updateZone(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $zone = $this->zone($storeId, $zoneId);
        $zone->update($request->validate(['name' => ['sometimes', 'string'], 'countries_json' => ['sometimes', 'array'], 'regions_json' => ['sometimes', 'array']]));

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

    public function storeRate(Request $request, int $storeId, int $zoneId): JsonResponse
    {
        $data = $request->validate(['name' => ['required', 'string'], 'type' => ['required', 'in:flat,weight,price,carrier'], 'config_json' => ['required', 'array'], 'is_active' => ['sometimes', 'boolean']]);
        $rate = $this->zone($storeId, $zoneId)->rates()->create($data);

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

    public function tax(int $storeId): JsonResponse
    {
        return response()->json(['data' => TaxSettings::withoutGlobalScopes()->where('store_id', $storeId)->firstOrFail()]);
    }

    public function updateTax(Request $request, int $storeId): JsonResponse
    {
        $data = $request->validate(['mode' => ['required', 'in:manual,provider'], 'provider' => ['required', 'in:none,stripe_tax'], 'prices_include_tax' => ['required', 'boolean'], 'config_json' => ['required', 'array']]);
        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);

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

    private function zone(int $storeId, int $id): ShippingZone
    {
        return ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);
    }
}

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

final class ProductController extends Controller
{
    public function __construct(private readonly ProductService $products) {}

    public function index(Request $request, int $storeId): JsonResponse
    {
        $validated = $request->validate([
            'status' => ['sometimes', 'in:draft,active,archived'],
            'query' => ['sometimes', 'string', 'max:200'],
            'collection_id' => ['sometimes', 'integer'],
            'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
            'sort' => ['sometimes', 'in:title_asc,title_desc,created_at_asc,created_at_desc,updated_at_desc'],
        ]);
        [$sort, $direction] = match ($validated['sort'] ?? 'updated_at_desc') {
            'title_asc' => ['title', 'asc'], 'title_desc' => ['title', 'desc'],
            'created_at_asc' => ['created_at', 'asc'], 'created_at_desc' => ['created_at', 'desc'],
            default => ['updated_at', 'desc'],
        };
        $products = Product::withoutGlobalScopes()->where('store_id', $storeId)
            ->when(isset($validated['status']), fn ($query) => $query->where('status', $validated['status']))
            ->when(isset($validated['query']), fn ($query) => $query->where(function ($nested) use ($validated): void {
                $term = $validated['query'];
                $nested->where('title', 'like', "%{$term}%")->orWhere('vendor', 'like', "%{$term}%")
                    ->orWhereHas('variants', fn ($variants) => $variants->where('sku', 'like', "%{$term}%"));
            }))
            ->when(isset($validated['collection_id']), fn ($query) => $query->whereHas('collections', fn ($collections) => $collections->whereKey($validated['collection_id'])))
            ->with(['variants.inventoryItem', 'media', 'collections'])->orderBy($sort, $direction)->paginate($validated['per_page'] ?? 25);

        return response()->json(['data' => $products->items(), 'meta' => ['current_page' => $products->currentPage(), 'last_page' => $products->lastPage(), 'total' => $products->total()]]);
    }

    public function store(Request $request, int $storeId): JsonResponse
    {
        $data = $this->validated($request, $storeId);
        $product = $this->products->create(app('current_store'), $data);

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

    public function show(int $storeId, int $productId): JsonResponse
    {
        return response()->json(['data' => $this->product($storeId, $productId)->load(['options.values', 'variants.optionValues', 'variants.inventoryItem', 'media', 'collections'])]);
    }

    public function update(Request $request, int $storeId, int $productId): JsonResponse
    {
        $data = $this->validated($request, $storeId, true);

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

    public function destroy(int $storeId, int $productId): JsonResponse
    {
        $product = $this->product($storeId, $productId);
        $this->products->transitionStatus($product, ProductStatus::Archived);

        return response()->json(['message' => 'Product archived.', 'data' => $product->refresh()]);
    }

    /** @return array<string, mixed> */
    private function validated(Request $request, int $storeId, bool $partial = false): array
    {
        $sometimes = $partial ? ['sometimes'] : ['required'];

        return $request->validate([
            'title' => [...$sometimes, 'string', 'max:255'],
            'handle' => ['sometimes', 'string', 'max:255', Rule::unique('products')->where('store_id', $storeId)->ignore($request->route('productId'))],
            'description_html' => ['nullable', 'string'],
            'vendor' => ['nullable', 'string', 'max:255'],
            'product_type' => ['nullable', 'string', 'max:255'],
            'tags' => ['sometimes', 'array'],
            'status' => ['sometimes', 'in:draft,active,archived'],
            'options' => ['sometimes', 'array', 'max:3'],
            'options.*.name' => ['required_with:options', 'string', 'max:100'],
            'options.*.values' => ['required_with:options', 'array', 'min:1'],
            'variant' => ['sometimes', 'array'],
            'variant.price_amount' => ['sometimes', 'integer', 'min:0'],
            'variant.sku' => ['nullable', 'string', 'max:100'],
            'variant.quantity_on_hand' => ['sometimes', 'integer'],
        ]);
    }

    private function product(int $storeId, int $productId): Product
    {
        return Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
    }
}

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Models\Collection;
use App\Support\HandleGenerator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

final class CollectionController extends Controller
{
    public function __construct(private readonly HandleGenerator $handles) {}

    public function index(Request $request, int $storeId): JsonResponse
    {
        $data = $request->validate(['status' => ['sometimes', 'in:draft,active,archived'], 'query' => ['sometimes', 'string'], 'per_page' => ['sometimes', 'integer', 'max:100']]);
        $items = Collection::withoutGlobalScopes()->where('store_id', $storeId)
            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))
            ->when(isset($data['query']), fn ($q) => $q->where('title', 'like', '%'.$data['query'].'%'))
            ->withCount('products')->paginate($data['per_page'] ?? 25);

        return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total(), 'current_page' => $items->currentPage()]]);
    }

    public function store(Request $request, int $storeId): JsonResponse
    {
        $data = $this->validateData($request, $storeId);
        $collection = Collection::withoutGlobalScopes()->create([
            ...$data,
            'store_id' => $storeId,
            'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'collections', $storeId),
        ]);
        $collection->products()->sync($this->positions($data['product_ids'] ?? []));

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

    public function update(Request $request, int $storeId, int $collectionId): JsonResponse
    {
        $collection = $this->find($storeId, $collectionId);
        $data = $this->validateData($request, $storeId, $collectionId, true);
        $collection->update(collect($data)->except('product_ids')->all());
        if (array_key_exists('product_ids', $data)) {
            $collection->products()->sync($this->positions($data['product_ids']));
        }

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

    public function destroy(int $storeId, int $collectionId): JsonResponse
    {
        $this->find($storeId, $collectionId)->delete();

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

    /** @return array<string, mixed> */
    private function validateData(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array
    {
        return $request->validate([
            'title' => [$partial ? 'sometimes' : 'required', 'string', 'max:255'],
            'handle' => ['sometimes', 'string', Rule::unique('collections')->where('store_id', $storeId)->ignore($ignore)],
            'description_html' => ['nullable', 'string'],
            'type' => ['sometimes', 'in:manual,automated'],
            'status' => ['sometimes', 'in:draft,active,archived'],
            'product_ids' => ['sometimes', 'array'],
            'product_ids.*' => ['integer'],
        ]);
    }

    /** @param list<int> $ids @return array<int, array{position: int}> */
    private function positions(array $ids): array
    {
        return collect(array_values($ids))->mapWithKeys(fn (int $id, int $position): array => [$id => ['position' => $position]])->all();
    }

    private function find(int $storeId, int $id): Collection
    {
        return Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);
    }
}

@@ -8,2 +8,3 @@
 use App\Http\Middleware\ResolveStore;
+use Laravel\Sanctum\Http\Middleware\CheckAbilities;

@@ -21,2 +22,3 @@
             'customer.auth' => CustomerAuthenticate::class,
+            'abilities' => CheckAbilities::class,
         ]);

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Services\FulfillmentService;
use App\Services\OrderService;
use App\Services\RefundService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

final class OrderController extends Controller
{
    public function __construct(
        private readonly FulfillmentService $fulfillments,
        private readonly RefundService $refunds,
        private readonly OrderService $orders,
    ) {}

    public function index(Request $request, int $storeId): JsonResponse
    {
        $data = $request->validate([
            'status' => ['sometimes', 'in:pending,paid,fulfilled,cancelled,refunded'],
            'financial_status' => ['sometimes', 'in:pending,authorized,paid,partially_refunded,refunded,voided'],
            'fulfillment_status' => ['sometimes', 'in:unfulfilled,partial,fulfilled'],
            'query' => ['sometimes', 'string'],
            'per_page' => ['sometimes', 'integer', 'max:100'],
        ]);
        $orders = Order::withoutGlobalScopes()->where('store_id', $storeId)
            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))
            ->when(isset($data['financial_status']), fn ($q) => $q->where('financial_status', $data['financial_status']))
            ->when(isset($data['fulfillment_status']), fn ($q) => $q->where('fulfillment_status', $data['fulfillment_status']))
            ->when(isset($data['query']), fn ($q) => $q->where(fn ($nested) => $nested->where('order_number', 'like', '%'.$data['query'].'%')->orWhere('email', 'like', '%'.$data['query'].'%')))
            ->with('customer')->latest('placed_at')->paginate($data['per_page'] ?? 25);

        return response()->json(['data' => $orders->items(), 'meta' => ['total' => $orders->total(), 'current_page' => $orders->currentPage()]]);
    }

    public function show(int $storeId, int $orderId): JsonResponse
    {
        return response()->json(['data' => $this->find($storeId, $orderId)->load(['lines', 'payments', 'refunds', 'fulfillments.lines', 'customer'])]);
    }

    public function fulfill(Request $request, int $storeId, int $orderId): JsonResponse
    {
        $data = $request->validate([
            'lines' => ['required', 'array', 'min:1'],
            'lines.*' => ['required', 'integer', 'min:1'],
            'tracking_company' => ['nullable', 'string'],
            'tracking_number' => ['nullable', 'string'],
            'tracking_url' => ['nullable', 'url'],
        ]);
        $tracking = collect($data)->only(['tracking_company', 'tracking_number', 'tracking_url'])->all();
        $fulfillment = $this->fulfillments->create($this->find($storeId, $orderId), $data['lines'], $tracking);

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

    public function refund(Request $request, int $storeId, int $orderId): JsonResponse
    {
        $data = $request->validate(['amount' => ['required', 'integer', 'min:1'], 'reason' => ['nullable', 'string'], 'restock' => ['sometimes', 'boolean'], 'lines' => ['sometimes', 'array']]);
        $order = $this->find($storeId, $orderId)->load('payments');
        $refund = $this->refunds->create($order, $order->payments->firstOrFail(), $data['amount'], $data['reason'] ?? null, (bool) ($data['restock'] ?? false), $data['lines'] ?? null);

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

    public function confirmPayment(int $storeId, int $orderId): JsonResponse
    {
        $order = $this->find($storeId, $orderId);
        $this->orders->confirmBankTransfer($order);

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

    private function find(int $storeId, int $id): Order
    {
        return Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);
    }
}

<?php

namespace App\Http\Controllers\Api\Admin;

use App\Http\Controllers\Controller;
use App\Models\Discount;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;

final class DiscountController extends Controller
{
    public function index(Request $request, int $storeId): JsonResponse
    {
        $data = $request->validate(['type' => ['sometimes', 'in:code,automatic'], 'status' => ['sometimes', 'in:draft,active,expired,disabled'], 'per_page' => ['sometimes', 'integer', 'max:100']]);
        $items = Discount::withoutGlobalScopes()->where('store_id', $storeId)
            ->when(isset($data['type']), fn ($q) => $q->where('type', $data['type']))
            ->when(isset($data['status']), fn ($q) => $q->where('status', $data['status']))
            ->latest()->paginate($data['per_page'] ?? 25);

        return response()->json(['data' => $items->items(), 'meta' => ['total' => $items->total()]]);
    }

    public function store(Request $request, int $storeId): JsonResponse
    {
        $discount = Discount::withoutGlobalScopes()->create(['store_id' => $storeId, ...$this->data($request, $storeId)]);

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

    public function update(Request $request, int $storeId, int $discountId): JsonResponse
    {
        $discount = $this->find($storeId, $discountId);
        $discount->update($this->data($request, $storeId, $discountId, true));

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

    public function destroy(int $storeId, int $discountId): JsonResponse
    {
        $this->find($storeId, $discountId)->delete();

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

    /** @return array<string, mixed> */
    private function data(Request $request, int $storeId, ?int $ignore = null, bool $partial = false): array
    {
        return $request->validate([
            'type' => [$partial ? 'sometimes' : 'required', 'in:code,automatic'],
            'code' => ['nullable', 'string', Rule::unique('discounts')->where('store_id', $storeId)->ignore($ignore)],
            'value_type' => [$partial ? 'sometimes' : 'required', 'in:fixed,percent,free_shipping'],
            'value_amount' => [$partial ? 'sometimes' : 'required', 'integer', 'min:0'],
            'starts_at' => [$partial ? 'sometimes' : 'required', 'date'],
            'ends_at' => ['nullable', 'date', 'after:starts_at'],
            'usage_limit' => ['nullable', 'integer', 'min:1'],
            'rules_json' => ['sometimes', 'array'],
            'status' => ['sometimes', 'in:draft,active,expired,disabled'],
        ]);
    }

    private function find(int $storeId, int $id): Discount
    {
        return Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($id);
    }
}


Script completed
Wall time 1.0 seconds
Output:


Input:
const r = await tools.exec_command({cmd:"sed -n '958,1018p' specs/02-API-ROUTES.md\nsed -n '1218,1394p' specs/02-API-ROUTES.md","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":30000});
text(r.output);


Script completed
Wall time 0.1 seconds
Output:

#### GET /api/storefront/v1/orders/{orderNumber}

Retrieve order status. Access is controlled via a signed token (included in confirmation emails and the confirmation page URL).

- **Authentication:** Tokenized access (`token` query parameter with HMAC signature)
- **Rate limit:** `api.storefront` (120/min per IP)

**Query parameters:**

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `token` | string | Yes | HMAC-signed access token for this order |

**Success response:** `200 OK`

```json
{
  "order_number": "#1001",
  "status": "paid",
  "financial_status": "paid",
  "fulfillment_status": "unfulfilled",
  "email": "customer@example.com",
  "currency": "EUR",
  "placed_at": "2026-02-11T10:10:00Z",
  "lines": [
    {
      "title_snapshot": "Classic T-Shirt",
      "variant_title": "Blue / Medium",
      "sku_snapshot": "TSH-BLU-M",
      "quantity": 2,
      "unit_price_amount": 2500,
      "total_amount": 5000
    }
  ],
  "totals": {
    "subtotal_amount": 5000,
    "discount_amount": 500,
    "shipping_amount": 500,
    "tax_amount": 950,
    "total_amount": 5950
  },
  "shipping_address": {
    "first_name": "Jane",
    "last_name": "Doe",
    "address1": "123 Main St",
    "city": "Berlin",
    "country": "DE",
    "postal_code": "10115"
  },
  "fulfillments": []
}
```

**Error responses:**
- `401` - Invalid or missing token
- `404` - Order not found

---

### 2.5 Search Endpoints

### 3.1 Platform Management (Super-Admin Only)

These endpoints are restricted to users with the `owner` (platform) role.

#### POST /api/admin/v1/platform/organizations

Create a new organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "name": "Acme Corp",
  "billing_email": "billing@acme.com"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `name` | string | Yes | Max: 255 |
| `billing_email` | string | Yes | Valid email, max: 255 |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "name": "Acme Corp",
    "billing_email": "billing@acme.com",
    "created_at": "2026-02-11T10:00:00Z",
    "updated_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors

---

#### POST /api/admin/v1/platform/stores

Create a new store within an organization.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "organization_id": 1,
  "name": "Acme Store",
  "handle": "acme",
  "default_currency": "EUR",
  "default_locale": "en",
  "timezone": "Europe/Berlin"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `organization_id` | integer | Yes | Must exist |
| `name` | string | Yes | Max: 255 |
| `handle` | string | Yes | Unique, lowercase, alphanumeric with hyphens. Max: 63. Regex: `^[a-z0-9]([a-z0-9-]*[a-z0-9])?$` |
| `default_currency` | string | Yes | Valid ISO 4217 code |
| `default_locale` | string | Yes | Valid locale code (e.g., `en`, `de`, `fr`) |
| `timezone` | string | Yes | Valid IANA timezone |

**Success response:** `201 Created`

```json
{
  "data": {
    "id": 1,
    "organization_id": 1,
    "name": "Acme Store",
    "handle": "acme",
    "status": "active",
    "default_currency": "EUR",
    "default_locale": "en",
    "timezone": "Europe/Berlin",
    "created_at": "2026-02-11T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions
- `422` - Validation errors (duplicate handle, invalid timezone, etc.)

---

#### POST /api/admin/v1/stores/{storeId}/invites

Invite a user to a store with a specific role.

- **Authentication:** Sanctum token with `manage-platform` scope
- **Rate limit:** `api.admin` (60/min per token)

**Request body:**

```json
{
  "email": "staff@acme.com",
  "role": "staff"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `email` | string | Yes | Valid email, max: 255 |
| `role` | string | Yes | One of: `owner`, `admin`, `staff`, `support` |

**Success response:** `201 Created`

```json
{
  "data": {
    "email": "staff@acme.com",
    "role": "staff",
    "invited_at": "2026-02-11T10:00:00Z",
    "expires_at": "2026-02-18T10:00:00Z"
  }
}
```

**Error responses:**
- `403` - Insufficient permissions (only `owner` and `admin` can invite)
- `409` - User already a member of this store
- `422` - Validation errors

---

#### GET /api/admin/v1/stores/{storeId}/me

Get the current user's membership details for a store.

- **Authentication:** Sanctum token
- **Rate limit:** `api.admin` (60/min per token)

**Success response:** `200 OK`

```json
{
  "data": {
    "user_id": 3,
    "store_id": 1,
    "role": "admin",
    "email": "admin@acme.com",
    "name": "Jane Admin",
    "permissions": [
      "read-products",
      "write-products",
      "read-orders",
      "write-orders",
      "read-settings",
      "write-settings",
      "read-customers"
    ]
  }
}
```

**Error responses:**
- `403` - Not a member of this store
- `404` - Store not found

---

### 3.2 Products