text:
Script completed
Wall time 0.1 seconds
Output:

text:
Warning: truncated output (original token count: 16514)
Total output lines: 868

specs/06-AUTH-AND-SECURITY.md-252-| `read-collections` | List and view collections |
specs/06-AUTH-AND-SECURITY.md-253-| `write-collections` | Create, update, delete collections |
specs/06-AUTH-AND-SECURITY.md-254-| `read-discounts` | List and view discounts |
specs/06-AUTH-AND-SECURITY.md-255-| `write-discounts` | Create, update, delete discounts |
specs/06-AUTH-AND-SECURITY.md:256:| `read-analytics` | View analytics data |
specs/06-AUTH-AND-SECURITY.md-257-| `read-settings` | View store settings (general, shipping, taxes, checkout, notifications) |
specs/06-AUTH-AND-SECURITY.md-258-| `write-settings` | Update store settings (replaces the legacy `manage-store`, `manage-shipping`, `manage-taxes` scopes) |
specs/06-AUTH-AND-SECURITY.md-259-| `read-themes` | View themes and theme files |
specs/06-AUTH-AND-SECURITY.md-260-| `write-themes` | Create, update, delete, publish themes |
--
specs/06-AUTH-AND-SECURITY.md-548-| Gate Name | Required Roles | Purpose |
specs/06-AUTH-AND-SECURITY.md-549-|-----------|---------------|---------|
specs/06-AUTH-AND-SECURITY.md-550-| `manage-store-settings` | Owner, Admin | Access store settings pages |
specs/06-AUTH-AND-SECURITY.md-551-| `manage-staff` | Owner, Admin | Invite, update, remove staff members |
specs/06-AUTH-AND-SECURITY.md:552:| `manage-developers` | Owner, Admin | Create/revoke API tokens, manage webhooks |
specs/06-AUTH-AND-SECURITY.md:553:| `view-analytics` | Owner, Admin, Staff | View analytics dashboard and reports |
specs/06-AUTH-AND-SECURITY.md-554-| `manage-shipping` | Owner, Admin | Manage shipping zones, rates, profiles |
specs/06-AUTH-AND-SECURITY.md-555-| `manage-taxes` | Owner, Admin | Manage tax settings and rates |
specs/06-AUTH-AND-SECURITY.md-556-| `manage-search-settings` | Owner, Admin | Configure search weights and synonyms |
specs/06-AUTH-AND-SECURITY.md-557-| `manage-navigation` | Owner, Admin | Create/edit/delete navigation menus |
--
specs/06-AUTH-AND-SECURITY.md-613-| Admin API | `api`, `auth:sanctum`, `store.resolve`, `throttle:api.admin` | REST API endpoints |
specs/06-AUTH-AND-SECURITY.md-614-| Storefront web (public) | `web`, `store.resolve` | Public store pages (catalog, product, cart) |
specs/06-AUTH-AND-SECURITY.md-615-| Storefront web (customer) | `web`, `store.resolve`, `auth:customer` | Customer account pages |
specs/06-AUTH-AND-SECURITY.md-616-| Storefront API | `api`, `store.resolve`, `throttle:api.storefront` | Cart and checkout API |
specs/06-AUTH-AND-SECURITY.md:617:| Webhook routes | `api`, `throttle:webhooks` | Reserved for outbound app webhooks (no inbound payment webhooks - Mock PSP is in-process) |
specs/06-AUTH-AND-SECURITY.md-618-
specs/06-AUTH-AND-SECURITY.md-619----
specs/06-AUTH-AND-SECURITY.md-620-
specs/06-AUTH-AND-SECURITY.md-621-### 3.3 Custom Middleware Specifications
--
specs/06-AUTH-AND-SECURITY.md-739-| `api.admin` | 60 per minute | User ID (if authenticated), otherwise IP | Admin REST API endpoints |
specs/06-AUTH-AND-SECURITY.md-740-| `api.storefront` | 120 per minute | IP address | Public storefront API (cart, search) |
specs/06-AUTH-AND-SECURITY.md-741-| `checkout` | 10 per minute | Session ID | Checkout-related operations |
specs/06-AUTH-AND-SECURITY.md-742-| `search` | 30 per minute | IP address | Search queries |
specs/06-AUTH-AND-SECURITY.md:743:| `analytics` | 60 per minute | IP address | Analytics event ingestion |
specs/06-AUTH-AND-SECURITY.md:744:| `webhooks` | 100 per minute | IP address | Outbound app webhook endpoints (reserved; no inbound payment webhooks - Mock PSP is in-process) |
specs/06-AUTH-AND-SECURITY.md-745-
specs/06-AUTH-AND-SECURITY.md-746-**Rate limit response headers** (returned on all API responses by Laravel default):
specs/06-AUTH-AND-SECURITY.md-747-
specs/06-AUTH-AND-SECURITY.md-748-| Header | Description |
--
specs/06-AUTH-AND-SECURITY.md-762-**Fields requiring encrypted storage (using Laravel's `encrypted` cast):**
specs/06-AUTH-AND-SECURITY.md-763-
specs/06-AUTH-AND-SECURITY.md-764-| Model | Column | Cast Type | Purpose |
specs/06-AUTH-AND-SECURITY.md-765-|-------|--------|-----------|---------|
specs/06-AUTH-AND-SECURITY.md:766:| OauthClient | `client_secret_encrypted` | `encrypted` | Third-party OAuth client secrets |
specs/06-AUTH-AND-SECURITY.md:767:| WebhookSubscription | `signing_secret_encrypted` | `encrypted` | Per-subscription signing secrets for outbound webhooks |
specs/06-AUTH-AND-SECURITY.md-768-| Payment | `raw_json` | `encrypted:array` | Full payment gateway response (may contain sensitive card/account data) |
specs/06-AUTH-AND-SECURITY.md-769-
specs/06-AUTH-AND-SECURITY.md-770-**Requirements:**
specs/06-AUTH-AND-SECURITY.md-771-
--
specs/06-AUTH-AND-SECURITY.md-933-## 7. Webhook Signature Verification
specs/06-AUTH-AND-SECURITY.md-934-
specs/06-AUTH-AND-SECURITY.md-935-### 7.1 Inbound Payment Webhooks
specs/06-AUTH-AND-SECURITY.md-936-
specs/06-AUTH-AND-SECURITY.md:937:> **Not applicable.** All payment processing happens in-process via the Mock PSP. There are no inbound payment webhooks from external providers (no Stripe, no PayPal). This section is preserved for reference only -- if a real PSP is integrated in the future, inbound webhook verification would follow the patterns described in the outbound webhook section below (HMAC signature verification).
specs/06-AUTH-AND-SECURITY.md-938-
specs/06-AUTH-AND-SECURITY.md-939----
specs/06-AUTH-AND-SECURITY.md-940-
specs/06-AUTH-AND-SECURITY.md-941-### 7.2 Platform Webhooks (Outbound)
specs/06-AUTH-AND-SECURITY.md-942-
specs/06-AUTH-AND-SECURITY.md-943-Service: `App\Services\WebhookDispatcher`
specs/06-AUTH-AND-SECURITY.md-944-
specs/06-AUTH-AND-SECURITY.md:945:When the platform sends webhooks to external subscribers (third-party apps, integrations).
specs/06-AUTH-AND-SECURITY.md-946-
specs/06-AUTH-AND-SECURITY.md-947-```mermaid
specs/06-AUTH-AND-SECURITY.md-948-sequenceDiagram
specs/06-AUTH-AND-SECURITY.md-949-    participant App as Platform
--
specs/06-AUTH-AND-SECURITY.md-1037-| Event ID | Trigger | Extra Data |
specs/06-AUTH-AND-SECURITY.md-1038-|----------|---------|------------|
specs/06-AUTH-AND-SECURITY.md-1039-| `api_token.created` | New API token issued | token_name, abilities list, user_id |
specs/06-AUTH-AND-SECURITY.md-1040-| `api_token.revoked` | API token revoked | token_name, user_id |
specs/06-AUTH-AND-SECURITY.md:1041:| `webhook.signature_invalid` | Outbound webhook delivery failed signature verification at subscriber | subscriber_url, ip |
specs/06-AUTH-AND-SECURITY.md-1042-| `store.settings_changed` | Store settings updated | changed fields with old/new values |
specs/06-AUTH-AND-SECURITY.md-1043-
specs/06-AUTH-AND-SECURITY.md-1044----
specs/06-AUTH-AND-SECURITY.md-1045-
--
specs/06-AUTH-AND-SECURITY.md-1071-### 8.5 Integration with Laravel Events
specs/06-AUTH-AND-SECURITY.md-1072-
specs/06-AUTH-AND-SECURITY.md-1073-The audit logger hooks into Laravel's built-in authentication events and custom model observers.
specs/06-AUTH-AND-SECURITY.md-1074-
specs/06-AUTH-AND-SECURITY.md:1075:#### Auth Event Listeners
specs/06-AUTH-AND-SECURITY.md-1076-
specs/06-AUTH-AND-SECURITY.md:1077:Register listeners (in `AppServiceProvider` or `EventServiceProvider`) for these Laravel events:
specs/06-AUTH-AND-SECURITY.md-1078-
specs/06-AUTH-AND-SECURITY.md-1079-| Laravel Event | Audit Event ID | Data Captured |
specs/06-AUTH-AND-SECURITY.md-1080-|--------------|----------------|---------------|
specs/06-AUTH-AND-SECURITY.md-1081-| `Illuminate\Auth\Events\Login` | `auth.login` | user_id from event |
--
specs/06-AUTH-AND-SECURITY.md-1096-| 2 | Session-based auth (customer) | Custom `customer` guard, store-scoped via custom user provider | Required |
specs/06-AUTH-AND-SECURITY.md-1097-| 3 | API token auth | Sanctum personal access tokens with per-token abilities | Required |
specs/06-AUTH-AND-SECURITY.md-1098-| 4 | Role-based access control | `StoreUserRole` enum, Laravel policies on all models, gates for non-model ops | Required |
specs/06-AUTH-AND-SECURITY.md-1099-| 5 | CSRF protection | `@csrf` on all session forms, Livewire auto-handles, API routes exempt | Required |
specs/06-AUTH-AND-SECURITY.md:1100:| 6 | Rate limiting | Seven named limiters (login, api.admin, api.storefront, checkout, search, analytics, webhooks) | Required |
specs/06-AUTH-AND-SECURITY.md-1101-| 7 | Input validation | Dedicated Form Request class for every write endpoint | Required |
specs/06-AUTH-AND-SECURITY.md-1102-| 8 | XSS prevention | Blade auto-escaping by default, HTMLPurifier for rich content fields | Required |
specs/06-AUTH-AND-SECURITY.md-1103-| 9 | SQL injection prevention | Eloquent ORM with parameterized queries, FTS5 input sanitization | Required |
specs/06-AUTH-AND-SECURITY.md-1104-| 10 | Mass assignment protection | `$fillable` on all models, always use `$request->validated()` | Required |
specs/06-AUTH-AND-SECURITY.md-1105-| 11 | Encryption at rest | Laravel `encrypted` cast for secrets and sensitive JSON columns | Required |
specs/06-AUTH-AND-SECURITY.md-1106-| 12 | Password security | Bcrypt, min 8 characters, `$hidden` on User and Customer models | Required |
specs/06-AUTH-AND-SECURITY.md-1107-| 13 | Session security | Encrypted sessions, HTTP-only cookies, secure flag, SameSite=Lax | Required |
specs/06-AUTH-AND-SECURITY.md:1108:| 14 | Webhook signature verification | HMAC-SHA256 for outbound platform webhooks (no inbound payment webhooks - Mock PSP is in-process) | Required |
specs/06-AUTH-AND-SECURITY.md-1109-| 15 | Audit logging | Structured JSON to dedicated daily-rotation log channel, 90-day retention | Required |
specs/06-AUTH-AND-SECURITY.md-1110-| 16 | CORS | Restricted to `api/*` paths, credentials supported, rate limit headers exposed | Required |
--
specs/05-BUSINESS-LOGIC.md-76-- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
specs/05-BUSINESS-LOGIC.md-77-- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.
specs/05-BUSINESS-LOGIC.md-78-
specs/05-BUSINESS-LOGIC.md-79-**Models that must use this trait:**
specs/05-BUSINESS-LOGIC.md:80:products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.
specs/05-BUSINESS-LOGIC.md-81-
specs/05-BUSINESS-LOGIC.md-82-### 1.3 Role-Based Access Control
specs/05-BUSINESS-LOGIC.md-83-
specs/05-BUSINESS-LOGIC.md-84-**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`
--
specs/05-BUSINESS-LOGIC.md-93-| Manage orders | Yes | Yes | Yes | Yes (read-only) |
specs/05-BUSINESS-LOGIC.md-94-| Manage discounts | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-95-| Manage themes | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-96-| Manage apps | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md:97:| View analytics | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-98-| Manage shipping/tax | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-99-| Process refunds | Yes | Yes | No | No |
specs/05-BUSINESS-LOGIC.md-100-| Create fulfillments | Yes | Yes | Yes | No |
specs/05-BUSINESS-LOGIC.md-101-
--
specs/05-BUSINESS-LOGIC.md-929-## 10. Payment Processing (Mock PSP)
specs/05-BUSINESS-LOGIC.md-930-
specs/05-BUSINESS-LOGIC.md-931-### 10.1 Mock Payment Provider Architecture
specs/05-BUSINESS-LOGIC.md-932-
specs/05-BUSINESS-LOGIC.md:933:The platform uses a **Mock Payment Service Provider (PSP)** instead of real payment gateways. All payment processing happens in-process with no external API calls, no webhooks, and no redirects. This enables a fully functional checkout flow for development, testing, and demonstration purposes.
specs/05-BUSINESS-LOGIC.md-934-
specs/05-BUSINESS-LOGIC.md-935-**Interface:** `App\Contracts\PaymentProvider`
specs/05-BUSINESS-LOGIC.md-936-
specs/05-BUSINESS-LOGIC.md-937-Must implement:
--
specs/05-BUSINESS-LOGIC.md-947-**Enum:** `App\Enums\PaymentMethod` - values: `CreditCard` (`credit_card`), `Paypal` (`paypal`), `BankTransfer` (`bank_transfer`)
specs/05-BUSINESS-LOGIC.md-948-
specs/05-BUSINESS-LOGIC.md-949-| Method | Behavior | Capture Timing |
specs/05-BUSINESS-LOGIC.md-950-|---|---|---|
specs/05-BUSINESS-LOGIC.md:951:| `credit_card` | Simulates card authorization and immediate capture. Uses magic card numbers for test scenarios. | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md:952:| `paypal` | Simulates PayPal approval and immediate capture. Always succeeds (no magic numbers). | Auto-capture on checkout (instant) |
specs/05-BUSINESS-LOGIC.md-953-| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md-954-
specs/05-BUSINESS-LOGIC.md-955-### 10.3 Magic Card Numbers (Credit Card Testing)
specs/05-BUSINESS-LOGIC.md-956-
--
specs/05-BUSINESS-LOGIC.md-1271-   - `tracking_company = null`, `tracking_number = null`, `tracking_url = null`
specs/05-BUSINESS-LOGIC.md-1272-   - `shipped_at = now`
specs/05-BUSINESS-LOGIC.md-1273-   - Set `order.fulfillment_status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md-1274-   - Set `order.status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1275:3. If the order contains a mix of physical and digital products: do **not** auto-fulfill. The admin must create fulfillments manually (they can fulfill digital items immediately and physical items when shipped).
specs/05-BUSINESS-LOGIC.md-1276-
specs/05-BUSINESS-LOGIC.md:1277:This ensures digital product orders are immediately marked as complete after payment.
specs/05-BUSINESS-LOGIC.md-1278-
specs/05-BUSINESS-LOGIC.md-1279-### 11.6 Order Export
specs/05-BUSINESS-LOGIC.md-1280-
specs/05-BUSINESS-LOGIC.md:1281:`GET /api/admin/v1/orders/export` returns a CSV file of orders matching the current filter criteria.
specs/05-BUSINESS-LOGIC.md-1282-
specs/05-BUSINESS-LOGIC.md-1283-**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
specs/05-BUSINESS-LOGIC.md-1284-
specs/05-BUSINESS-LOGIC.md:1285:All monetary values are exported in minor units (cents). Dates use ISO-8601 format. The response uses `Content-Type: text/csv` with `Content-Disposition: attachment; filename="orders-{date}.csv"`.
specs/05-BUSINESS-LOGIC.md-1286-
specs/05-BUSINESS-LOGIC.md-1287----
specs/05-BUSINESS-LOGIC.md-1288-
specs/05-BUSINESS-LOGIC.md-1289-## 12. Customer Accounts
--
specs/05-BUSINESS-LOGIC.md-1345-## 13. Webhook Delivery System
specs/05-BUSINESS-LOGIC.md-1346-
specs/05-BUSINESS-LOGIC.md-1347-### 13.1 Subscription Model
specs/05-BUSINESS-LOGIC.md-1348-
specs/05-BUSINESS-LOGIC.md:1349:Stores (or app installations) subscribe to event types via `webhook_subscriptions`.
specs/05-BUSINESS-LOGIC.md-1350-
specs/05-BUSINESS-LOGIC.md-1351-**Supported event types:**
specs/05-BUSINESS-LOGIC.md-1352-
specs/05-BUSINESS-LOGIC.md-1353-| Event Type | Triggered By |
--
specs/05-BUSINESS-LOGIC.md-1362-| `checkout.completed` | Checkout successfully completed |
specs/05-BUSINESS-LOGIC.md-1363-
specs/05-BUSINESS-LOGIC.md-1364-### 13.2 Delivery Process
specs/05-BUSINESS-LOGIC.md-1365-
specs/05-BUSINESS-LOGIC.md:1366:**Listener:** `App\Listeners\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.
specs/05-BUSINESS-LOGIC.md-1367-
specs/05-BUSINESS-LOGIC.md-1368-**Job:** `App\Jobs\DeliverWebhook`
specs/05-BUSINESS-LOGIC.md-1369-
specs/05-BUSINESS-LOGIC.md-1370-```mermaid
specs/05-BUSINESS-LOGIC.md-1371-sequenceDiagram
specs/05-BUSINESS-LOGIC.md-1372-    participant DomainEvent
specs/05-BUSINESS-LOGIC.md:1373:    participant Listener
specs/05-BUSINESS-LOGIC.md-1374-    participant Queue
specs/05-BUSINESS-LOGIC.md-1375-    participant Job
specs/05-BUSINESS-LOGIC.md-1376-    participant TargetURL
specs/05-BUSINESS-LOGIC.md-1377-
specs/05-BUSINESS-LOGIC.md:1378:    DomainEvent->>Listener: Event fired (e.g., OrderCreated)
specs/05-BUSINESS-LOGIC.md:1379:    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
specs/05-BUSINESS-LOGIC.md-1380-    loop For each matching subscription
specs/05-BUSINESS-LOGIC.md:1381:        Listener->>Queue: Dispatch DeliverWebhook job
specs/05-BUSINESS-LOGIC.md-1382-    end
specs/05-BUSINESS-LOGIC.md-1383-    Queue->>Job: Process job
specs/05-BUSINESS-LOGIC.md-1384-    Job->>Job: Serialize event payload as JSON
specs/05-BUSINESS-LOGIC.md-1385-    Job->>Job: Generate HMAC-SHA256 signature using subscription's signing_secret (decrypted)
specs/05-BUSINESS-LOGIC.md-1386-    Job->>TargetURL: HTTP POST with headers and payload
specs/05-BUSINESS-LOGIC.md-1387-    TargetURL-->>Job: Response
specs/05-BUSINESS-LOGIC.md:1388:    Job->>Job: Record result in webhook_deliveries
specs/05-BUSINESS-LOGIC.md-1389-```
specs/05-BUSINESS-LOGIC.md-1390-
specs/05-BUSINESS-LOGIC.md-1391-**Required HTTP headers on delivery:**
specs/05-BUSINESS-LOGIC.md-1392-
--
specs/05-BUSINESS-LOGIC.md-1403-On non-2xx response or connection timeout:
specs/05-BUSINESS-LOGIC.md-1404-
specs/05-BUSINESS-LOGIC.md-1405-| Attempt | Backoff Delay |
specs/05-BUSINESS-LOGIC.md-1406-|---|---|
specs/05-BUSINESS-LOGIC.md:1407:| 1 (initial) | Immediate |
specs/05-BUSINESS-LOGIC.md-1408-| 2 | 1 minute |
specs/05-BUSINESS-LOGIC.md-1409-| 3 | 5 minutes |
specs/05-BUSINESS-LOGIC.md-1410-| 4 | 30 minutes |
specs/05-BUSINESS-LOGIC.md-1411-| 5 | 2 hours |
--
specs/05-BUSINESS-LOGIC.md-1417-
specs/05-BUSINESS-LOGIC.md-1418-### 13.4 Circuit Breaker
specs/05-BUSINESS-LOGIC.md-1419-
specs/05-BUSINESS-LOGIC.md-1420-- Track consecutive failures per subscription (across different events).
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md-1422-- Log a warning and (future) send an admin notification.
specs/05-BUSINESS-LOGIC.md-1423-- Manual re-enable required by the merchant through the admin UI.
specs/05-BUSINESS-LOGIC.md-1424-- On each successful delivery, reset the consecutive failure counter to 0.
specs/05-BUSINESS-LOGIC.md-1425-
--
specs/05-BUSINESS-LOGIC.md-1430-## 14. Analytics
specs/05-BUSINESS-LOGIC.md-1431-
specs/05-BUSINESS-LOGIC.md-1432-### 14.1 Event Ingestion
specs/05-BUSINESS-LOGIC.md-1433-
specs/05-BUSINESS-LOGIC.md:1434:**Endpoint:** `POST /api/storefront/v1/analytics/events`
specs/05-BUSINESS-LOGIC.md-1435-
specs/05-BUSINESS-LOGIC.md-1436-**Request structure:**
specs/05-BUSINESS-LOGIC.md-1437-
specs/05-BUSINESS-LOGIC.md-1438-| Field | Type | Required | Description |
--
specs/05-BUSINESS-LOGIC.md-1471-
specs/05-BUSINESS-LOGIC.md-1472-**Job:** `App\Jobs\AggregateAnalytics`
specs/05-BUSINESS-LOGIC.md-1473-
specs/05-BUSINESS-LOGIC.md-1474-- Schedule: daily at 01:00 UTC
specs/05-BUSINESS-LOGIC.md:1475:- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.
specs/05-BUSINESS-LOGIC.md-1476-
specs/05-BUSINESS-LOGIC.md-1477-**Aggregated metrics:**
specs/05-BUSINESS-LOGIC.md-1478-
specs/05-BUSINESS-LOGIC.md-1479-| Metric | Calculation |
--
specs/05-BUSINESS-LOGIC.md-1492-### 15.1 Upload Flow
specs/05-BUSINESS-LOGIC.md-1493-
specs/05-BUSINESS-LOGIC.md-1494-```mermaid
specs/05-BUSINESS-LOGIC.md-1495-flowchart TD
specs/05-BUSINESS-LOGIC.md:1496:    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
specs/05-BUSINESS-LOGIC.md:1497:    B --> C[Create product_media record with status = processing]
specs/05-BUSINESS-LOGIC.md-1498-    C --> D[Dispatch ProcessMediaUpload job to queue]
specs/05-BUSINESS-LOGIC.md-1499-    D --> E{Processing succeeds?}
specs/05-BUSINESS-LOGIC.md-1500-    E -->|Yes| F[Store resized versions, set status = ready]
specs/05-BUSINESS-LOGIC.md-1501-    E …6514 tokens truncated…-
specs/09-IMPLEMENTATION-ROADMAP.md-1776-| Name | Purpose |
--
specs/09-IMPLEMENTATION-ROADMAP.md-1842-### Jobs
specs/09-IMPLEMENTATION-ROADMAP.md-1843-
specs/09-IMPLEMENTATION-ROADMAP.md-1844-| Name | Purpose |
specs/09-IMPLEMENTATION-ROADMAP.md-1845-|------|---------|
specs/09-IMPLEMENTATION-ROADMAP.md:1846:| `ProcessMediaUpload` | Resize images, update media status |
specs/09-IMPLEMENTATION-ROADMAP.md:1847:| `DeliverWebhook` | HTTP POST webhook with retry logic |
specs/09-IMPLEMENTATION-ROADMAP.md:1848:| `AggregateAnalytics` | Daily analytics aggregation |
specs/09-IMPLEMENTATION-ROADMAP.md-1849-| `CleanupAbandonedCarts` | Mark old active carts as abandoned |
specs/09-IMPLEMENTATION-ROADMAP.md-1850-| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |
specs/09-IMPLEMENTATION-ROADMAP.md-1851-
specs/09-IMPLEMENTATION-ROADMAP.md-1852-### Events
--
specs/09-IMPLEMENTATION-ROADMAP.md-1933-| `config/auth.php` | Customer guard | Add customer guard, customer provider, customer password broker | Customer storefront auth |
specs/09-IMPLEMENTATION-ROADMAP.md-1934-| `config/session.php` | Driver | File driver, lifetime 120 | Session configuration |
specs/09-IMPLEMENTATION-ROADMAP.md-1935-| `config/cache.php` | Driver | File driver | Cache configuration |
specs/09-IMPLEMENTATION-ROADMAP.md-1936-| `config/queue.php` | Driver | Sync driver | Queue configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:1937:| `config/filesystems.php` | Public disk | Media storage configuration | Public disk for media |
specs/09-IMPLEMENTATION-ROADMAP.md-1938-| `config/logging.php` | JSON channel | Structured JSON channel | Structured logging |
specs/09-IMPLEMENTATION-ROADMAP.md-1939-
specs/09-IMPLEMENTATION-ROADMAP.md-1940-### bootstrap/app.php Changes
specs/09-IMPLEMENTATION-ROADMAP.md-1941-
specs/09-IMPLEMENTATION-ROADMAP.md-1942-- Register middleware aliases: `store.resolve`, `role.check`, `auth:customer`
specs/09-IMPLEMENTATION-ROADMAP.md-1943-- Register middleware groups for `storefront` and `admin` route groups
specs/09-IMPLEMENTATION-ROADMAP.md:1944:- Configure rate limiters: `login` (5/min per IP), `api.admin` (60/min per token), `api.storefront` (120/min per IP), `checkout` (10/min per session), `search` (30/min per IP), `analytics` (60/min per IP), `webhooks` (100/min per IP)
specs/09-IMPLEMENTATION-ROADMAP.md-1945-- Configure exception handling
specs/09-IMPLEMENTATION-ROADMAP.md-1946-
specs/09-IMPLEMENTATION-ROADMAP.md-1947-### Routes Files
specs/09-IMPLEMENTATION-ROADMAP.md-1948-
--
specs/09-IMPLEMENTATION-ROADMAP.md-2051-6. `StoreSettingsSeeder`
specs/09-IMPLEMENTATION-ROADMAP.md-2052-7. `TaxSettingsSeeder`
specs/09-IMPLEMENTATION-ROADMAP.md-2053-8. `ShippingSeeder` - creates shipping zones and rates
specs/09-IMPLEMENTATION-ROADMAP.md-2054-9. `CollectionSeeder` - creates collections (before products so products can be linked)
specs/09-IMPLEMENTATION-ROADMAP.md:2055:10. `ProductSeeder` - creates products with options, variants, inventory, media, and collection associations
specs/09-IMPLEMENTATION-ROADMAP.md-2056-11. `DiscountSeeder`
specs/09-IMPLEMENTATION-ROADMAP.md-2057-12. `CustomerSeeder` - creates customers and their addresses
specs/09-IMPLEMENTATION-ROADMAP.md-2058-13. `OrderSeeder` - creates sample orders with lines, payments, fulfillments
specs/09-IMPLEMENTATION-ROADMAP.md-2059-14. `ThemeSeeder` - creates themes with files and settings
--
specs/02-API-ROUTES.md-46-| Method | Path | Description | Component |
specs/02-API-ROUTES.md-47-|--------|------|-------------|-----------|
specs/02-API-ROUTES.md-48-| GET | `/admin/products` | Product list - searchable, filterable by status, bulk actions | `Admin\Products\Index` |
specs/02-API-ROUTES.md-49-| GET | `/admin/products/create` | Create product form | `Admin\Products\Create` |
specs/02-API-ROUTES.md:50:| GET | `/admin/products/{product}/edit` | Edit product form with variants, media, options | `Admin\Products\Edit` |
specs/02-API-ROUTES.md-51-
specs/02-API-ROUTES.md-52-#### Inventory
specs/02-API-ROUTES.md-53-
specs/02-API-ROUTES.md-54-| Method | Path | Description | Component |
--
specs/02-API-ROUTES.md-118-
specs/02-API-ROUTES.md-119-| Method | Path | Description | Component |
specs/02-API-ROUTES.md-120-|--------|------|-------------|-----------|
specs/02-API-ROUTES.md-121-| GET | `/admin/apps` | Apps directory - list installed and available apps | `Admin\Apps\Index` |
specs/02-API-ROUTES.md:122:| GET | `/admin/apps/{installation}` | Installed app detail - scopes, webhooks, usage | `Admin\Apps\Show` |
specs/02-API-ROUTES.md-123-
specs/02-API-ROUTES.md-124-#### Developers
specs/02-API-ROUTES.md-125-
specs/02-API-ROUTES.md-126-| Method | Path | Description | Component |
specs/02-API-ROUTES.md-127-|--------|------|-------------|-----------|
specs/02-API-ROUTES.md:128:| GET | `/admin/developers` | Developer settings - API keys/tokens, webhook management | `Admin\Developers\Index` |
specs/02-API-ROUTES.md-129-
specs/02-API-ROUTES.md:130:> **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.
specs/02-API-ROUTES.md-131-
specs/02-API-ROUTES.md-132-#### Analytics
specs/02-API-ROUTES.md-133-
specs/02-API-ROUTES.md-134-| Method | Path | Description | Component |
specs/02-API-ROUTES.md-135-|--------|------|-------------|-----------|
specs/02-API-ROUTES.md:136:| GET | `/admin/analytics` | Analytics dashboard - sales, traffic, funnel, exports | `Admin\Analytics\Index` |
specs/02-API-ROUTES.md-137-
specs/02-API-ROUTES.md-138-#### Search Settings
specs/02-API-ROUTES.md-139-
specs/02-API-ROUTES.md-140-| Method | Path | Description | Component |
--
specs/02-API-ROUTES.md-199-**Prefix:** `/api/storefront/v1`
specs/02-API-ROUTES.md-200-**Middleware:** `store.resolve`, `throttle:api.storefront`
specs/02-API-ROUTES.md-201-**Content-Type:** `application/json`
specs/02-API-ROUTES.md-202-
specs/02-API-ROUTES.md:203:These endpoints power the interactive storefront experience (cart drawer, checkout flow, search autocomplete, analytics).
specs/02-API-ROUTES.md-204-
specs/02-API-ROUTES.md-205----
specs/02-API-ROUTES.md-206-
specs/02-API-ROUTES.md-207-### 2.1 Cart Endpoints
--
specs/02-API-ROUTES.md-846-### 2.3 Payment Endpoint
specs/02-API-ROUTES.md-847-
specs/02-API-ROUTES.md-848-#### POST /api/storefront/v1/checkouts/{checkoutId}/pay
specs/02-API-ROUTES.md-849-
specs/02-API-ROUTES.md:850:Process payment for a checkout using the selected payment method. All payment processing happens in-process via the Mock PSP - no external API calls, no redirects, no webhooks.
specs/02-API-ROUTES.md-851-
specs/02-API-ROUTES.md-852-- **Authentication:** None
specs/02-API-ROUTES.md-853-- **Rate limit:** `checkout` (10/min per session)
specs/02-API-ROUTES.md-854-
--
specs/02-API-ROUTES.md-1139----
specs/02-API-ROUTES.md-1140-
specs/02-API-ROUTES.md-1141-### 2.6 Analytics Endpoints
specs/02-API-ROUTES.md-1142-
specs/02-API-ROUTES.md:1143:#### POST /api/storefront/v1/analytics/events
specs/02-API-ROUTES.md-1144-
specs/02-API-ROUTES.md:1145:Batch submit storefront analytics events.
specs/02-API-ROUTES.md-1146-
specs/02-API-ROUTES.md-1147-- **Authentication:** None
specs/02-API-ROUTES.md:1148:- **Rate limit:** `analytics` (60/min per IP)
specs/02-API-ROUTES.md-1149-
specs/02-API-ROUTES.md-1150-**Request body:**
specs/02-API-ROUTES.md-1151-
specs/02-API-ROUTES.md-1152-```json
--
specs/02-API-ROUTES.md-1400-| POST | `/api/admin/v1/stores/{storeId}/products` | `write-products` | Create a new product | `api.admin` (60/min) |
specs/02-API-ROUTES.md-1401-| GET | `/api/admin/v1/stores/{storeId}/products/{productId}` | `read-products` | Get single product with all relations | `api.admin` (60/min) |
specs/02-API-ROUTES.md-1402-| PUT | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Update a product (partial updates supported) | `api.admin` (60/min) |
specs/02-API-ROUTES.md-1403-| DELETE | `/api/admin/v1/stores/{storeId}/products/{productId}` | `write-products` | Archive a product (soft delete) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:1404:| POST | `/api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload` | `write-products` | Generate presigned URL for media upload | `api.admin` (60/min) |
specs/02-API-ROUTES.md-1405-
specs/02-API-ROUTES.md-1406-#### GET /api/admin/v1/stores/{storeId}/products
specs/02-API-ROUTES.md-1407-
specs/02-API-ROUTES.md-1408-List products with filtering and pagination.
--
specs/02-API-ROUTES.md-1535-| `variants[].inventory.policy` | string | No | One of: `deny`, `continue`. Default: `deny`. |
specs/02-API-ROUTES.md-1536-
specs/02-API-ROUTES.md-1537-**Success response:** `201 Created`
specs/02-API-ROUTES.md-1538-
specs/02-API-ROUTES.md:1539:Returns the full product resource with nested variants, options, and media.
specs/02-API-ROUTES.md-1540-
specs/02-API-ROUTES.md-1541-**Error responses:**
specs/02-API-ROUTES.md-1542-- `403` - Insufficient permissions
specs/02-API-ROUTES.md-1543-- `422` - Validation errors (duplicate SKU, duplicate handle, missing required fields)
--
specs/02-API-ROUTES.md-1545----
specs/02-API-ROUTES.md-1546-
specs/02-API-ROUTES.md-1547-#### GET /api/admin/v1/stores/{storeId}/products/{productId}
specs/02-API-ROUTES.md-1548-
specs/02-API-ROUTES.md:1549:Get a single product with all variants, options, media, and collections.
specs/02-API-ROUTES.md-1550-
specs/02-API-ROUTES.md-1551-**Success response:** `200 OK`
specs/02-API-ROUTES.md-1552-
specs/02-API-ROUTES.md-1553-```json
--
specs/02-API-ROUTES.md-1609-          "policy": "deny"
specs/02-API-ROUTES.md-1610-        }
specs/02-API-ROUTES.md-1611-      }
specs/02-API-ROUTES.md-1612-    ],
specs/02-API-ROUTES.md:1613:    "media": [
specs/02-API-ROUTES.md-1614-      {
specs/02-API-ROUTES.md-1615-        "id": 1,
specs/02-API-ROUTES.md-1616-        "type": "image",
specs/02-API-ROUTES.md-1617-        "storage_key": "stores/1/products/10/main.jpg",
--
specs/02-API-ROUTES.md-1690-- `404` - Product not found
specs/02-API-ROUTES.md-1691-
specs/02-API-ROUTES.md-1692----
specs/02-API-ROUTES.md-1693-
specs/02-API-ROUTES.md:1694:#### POST /api/admin/v1/stores/{storeId}/products/{productId}/media/presign-upload
specs/02-API-ROUTES.md-1695-
specs/02-API-ROUTES.md:1696:Generate a presigned URL for uploading product media directly to object storage (S3).
specs/02-API-ROUTES.md-1697-
specs/02-API-ROUTES.md-1698-**Request body:**
specs/02-API-ROUTES.md-1699-
specs/02-API-ROUTES.md-1700-```json
--
specs/02-API-ROUTES.md-1719-  "method": "PUT",
specs/02-API-ROUTES.md-1720-  "headers": {
specs/02-API-ROUTES.md-1721-    "Content-Type": "image/jpeg"
specs/02-API-ROUTES.md-1722-  },
specs/02-API-ROUTES.md:1723:  "storage_key": "stores/1/products/10/media/uuid.jpg",
specs/02-API-ROUTES.md:1724:  "media_id": 2,
specs/02-API-ROUTES.md-1725-  "expires_at": "2026-02-11T10:10:00Z"
specs/02-API-ROUTES.md-1726-}
specs/02-API-ROUTES.md-1727-```
specs/02-API-ROUTES.md-1728-
--
specs/02-API-ROUTES.md-2766-#### Admin Analytics Endpoint Summary
specs/02-API-ROUTES.md-2767-
specs/02-API-ROUTES.md-2768-| Method | Path | Auth Scope | Description | Rate Limit |
specs/02-API-ROUTES.md-2769-|--------|------|------------|-------------|------------|
specs/02-API-ROUTES.md:2770:| GET | `/api/admin/v1/stores/{storeId}/analytics/summary` | `read-analytics` | Get analytics summary for date range | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2771:| POST | `/api/admin/v1/stores/{storeId}/exports/orders` | `read-orders` | Queue an order export job (async) | `api.admin` (60/min) |
specs/02-API-ROUTES.md:2772:| GET | `/api/admin/v1/stores/{storeId}/exports/{exportId}` | `read-orders` | Check export status and get download URL | `api.admin` (60/min) |
specs/02-API-ROUTES.md-2773-
specs/02-API-ROUTES.md:2774:#### GET /api/admin/v1/stores/{storeId}/analytics/summary
specs/02-API-ROUTES.md-2775-
specs/02-API-ROUTES.md:2776:Get analytics summary for a date range.
specs/02-API-ROUTES.md-2777-
specs/02-API-ROUTES.md-2778-**Query parameters:**
specs/02-API-ROUTES.md-2779-
specs/02-API-ROUTES.md-2780-| Parameter | Type | Required | Description |
--
specs/02-API-ROUTES.md-2830-- `422` - Invalid date range
specs/02-API-ROUTES.md-2831-
specs/02-API-ROUTES.md-2832----
specs/02-API-ROUTES.md-2833-
specs/02-API-ROUTES.md:2834:#### POST /api/admin/v1/stores/{storeId}/exports/orders
specs/02-API-ROUTES.md-2835-
specs/02-API-ROUTES.md:2836:Queue an order export job. The export runs asynchronously and produces a downloadable CSV.
specs/02-API-ROUTES.md-2837-
specs/02-API-ROUTES.md-2838-**Request body:**
specs/02-API-ROUTES.md-2839-
specs/02-API-ROUTES.md-2840-```json
--
specs/02-API-ROUTES.md-2856-**Success response:** `202 Accepted`
specs/02-API-ROUTES.md-2857-
specs/02-API-ROUTES.md-2858-```json
specs/02-API-ROUTES.md-2859-{
specs/02-API-ROUTES.md:2860:  "export_id": 1,
specs/02-API-ROUTES.md-2861-  "status": "queued",
specs/02-API-ROUTES.md-2862-  "created_at": "2026-02-11T10:00:00Z"
specs/02-API-ROUTES.md-2863-}
specs/02-API-ROUTES.md-2864-```
specs/02-API-ROUTES.md-2865-
specs/02-API-ROUTES.md-2866----
specs/02-API-ROUTES.md-2867-
specs/02-API-ROUTES.md:2868:#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}
specs/02-API-ROUTES.md-2869-
specs/02-API-ROUTES.md:2870:Check export status and get download URL.
specs/02-API-ROUTES.md-2871-
specs/02-API-ROUTES.md-2872-**Success response:** `200 OK`
specs/02-API-ROUTES.md-2873-
specs/02-API-ROUTES.md-2874-```json
--
specs/02-API-ROUTES.md-2895----
specs/02-API-ROUTES.md-2896-
specs/02-API-ROUTES.md-2897-## 4. Webhook Receiver Endpoints
specs/02-API-ROUTES.md-2898-
specs/02-API-ROUTES.md:2899:> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).
specs/02-API-ROUTES.md-2900-
specs/02-API-ROUTES.md-2901----
specs/02-API-ROUTES.md-2902-
specs/02-API-ROUTES.md:2903:## 5. OAuth Endpoints (App Ecosystem)
specs/02-API-ROUTES.md-2904-
specs/02-API-ROUTES.md:2905:> **Scope note:** OAuth and app ecosystem endpoints are deferred for initial implementation. The route structure below is defined for future use but implementations should return 501 Not Implemented.
specs/02-API-ROUTES.md-2906-
specs/02-API-ROUTES.md:2907:These endpoints implement OAuth2 authorization for third-party apps, powered by Laravel Passport.
specs/02-API-ROUTES.md-2908-
specs/02-API-ROUTES.md-2909----
specs/02-API-ROUTES.md-2910-
specs/02-API-ROUTES.md:2911:### GET /oauth/authorize
specs/02-API-ROUTES.md-2912-
specs/02-API-ROUTES.md-2913-Display the authorization prompt to the merchant.
specs/02-API-ROUTES.md-2914-
specs/02-API-ROUTES.md-2915-- **Middleware:** `web`, `auth`
--
specs/02-API-ROUTES.md-2917-**Query parameters:**
specs/02-API-ROUTES.md-2918-
specs/02-API-ROUTES.md-2919-| Parameter | Type | Required | Description |
specs/02-API-ROUTES.md-2920-|-----------|------|----------|-------------|
specs/02-API-ROUTES.md:2921:| `client_id` | string | Yes | The app's OAuth client ID |
specs/02-API-ROUTES.md-2922-| `redirect_uri` | string | Yes | Must match one of the registered redirect URIs |
specs/02-API-ROUTES.md-2923-| `response_type` | string | Yes | Must be `code` |
specs/02-API-ROUTES.md-2924-| `scope` | string | Yes | Space-separated list of requested scopes |
specs/02-API-ROUTES.md-2925-| `state` | string | Yes | CSRF token for the client |
--
specs/02-API-ROUTES.md-2927-**Flow:** Merchant reviews requested scopes and approves/denies. On approval, redirects to `redirect_uri` with `code` and `state` parameters. Authorization code is single-use.
specs/02-API-ROUTES.md-2928-
specs/02-API-ROUTES.md-2929----
specs/02-API-ROUTES.md-2930-
specs/02-API-ROUTES.md:2931:### POST /oauth/token
specs/02-API-ROUTES.md-2932-
specs/02-API-ROUTES.md-2933-Exchange an authorization code for access and refresh tokens.
specs/02-API-ROUTES.md-2934-
specs/02-API-ROUTES.md-2935-- **Middleware:** None (public endpoint)
--
specs/02-API-ROUTES.md-2975----
specs/02-API-ROUTES.md-2976-
specs/02-API-ROUTES.md-2977-### App API Endpoints
specs/02-API-ROUTES.md-2978-
specs/02-API-ROUTES.md:2979:Third-party apps use OAuth tokens to access store data via scoped endpoints.
specs/02-API-ROUTES.md-2980-
specs/02-API-ROUTES.md-2981-| Method | Path | Scope Required | Description |
specs/02-API-ROUTES.md-2982-|--------|------|----------------|-------------|
specs/02-API-ROUTES.md:2983:| GET | `/api/apps/v1/stores/{storeId}/products` | `read-products` | List products (same schema as admin API) |
specs/02-API-ROUTES.md:2984:| GET | `/api/apps/v1/stores/{storeId}/orders` | `read-orders` | List orders |
specs/02-API-ROUTES.md:2985:| GET | `/api/apps/v1/stores/{storeId}/customers` | `read-customers` | List customers |
specs/02-API-ROUTES.md-2986-
specs/02-API-ROUTES.md-2987-Token scopes are enforced on every endpoint. Requests with insufficient scopes return `403 Forbidden`.
specs/02-API-ROUTES.md-2988-
specs/02-API-ROUTES.md-2989----
--
specs/02-API-ROUTES.md-3081-| `api.admin` | 60 requests | 1 minute | Sanctum token ID | Admin REST API endpoints |
specs/02-API-ROUTES.md-3082-| `api.storefront` | 120 requests | 1 minute | IP address | Storefront REST API (carts, etc.) |
specs/02-API-ROUTES.md-3083-| `checkout` | 10 requests | 1 minute | Session ID | Checkout creation and mutation endpoints |
specs/02-API-ROUTES.md-3084-| `search` | 30 requests | 1 minute | IP address | Search and autocomplete endpoints |
specs/02-API-ROUTES.md:3085:| `analytics` | 60 requests | 1 minute | IP address | Analytics event ingestion |
specs/02-API-ROUTES.md:3086:| `webhooks` | 100 requests | 1 minute | IP address | Reserved for future outbound app webhook endpoints |
specs/02-API-ROUTES.md-3087-
specs/02-API-ROUTES.md-3088-### Rate Limit Response
specs/02-API-ROUTES.md-3089-
specs/02-API-ROUTES.md-3090-When a rate limit is exceeded, the server returns `429 Too Many Requests`:
--
specs/02-API-ROUTES.md-3121-| `POST /api/storefront/v1/checkouts/*/pay` | `checkout` |
specs/02-API-ROUTES.md-3122-| `GET /api/storefront/v1/orders*` | `api.storefront` |
specs/02-API-ROUTES.md-3123-| `GET /api/storefront/v1/search` | `search` |
specs/02-API-ROUTES.md-3124-| `GET /api/storefront/v1/search/suggest` | `search` |
specs/02-API-ROUTES.md:3125:| `POST /api/storefront/v1/analytics/events` | `analytics` |
specs/02-API-ROUTES.md-3126-| `GET /api/admin/v1/*` | `api.admin` |
specs/02-API-ROUTES.md-3127-| `POST /api/admin/v1/*` | `api.admin` |
specs/02-API-ROUTES.md-3128-| `PUT /api/admin/v1/*` | `api.admin` |
specs/02-API-ROUTES.md-3129-| `DELETE /api/admin/v1/*` | `api.admin` |
specs/02-API-ROUTES.md:3130:| *(no inbound webhook endpoints - mock PSP)* | - |
specs/02-API-ROUTES.md-3131-
specs/02-API-ROUTES.md-3132----
specs/02-API-ROUTES.md-3133-
specs/02-API-ROUTES.md-3134-## 8. Checkout State Machine
--
specs/02-API-ROUTES.md-3169-## 9. API Versioning Strategy
specs/02-API-ROUTES.md-3170-
specs/02-API-ROUTES.md-3171-- **Storefront API:** Versioned via URL path (`/api/storefront/v1/`). Breaking changes require a new version.
specs/02-API-ROUTES.md-3172-- **Admin API:** Versioned via URL path (`/api/admin/v1/`). Same strategy.
specs/02-API-ROUTES.md:3173:- **App API:** Versioned via URL path (`/api/apps/v1/`).
specs/02-API-ROUTES.md:3174:- **Outbound webhook payloads:** Include a `api_version` field in outbound webhook deliveries to app installations. Each app installation pins to a version.
specs/02-API-ROUTES.md-3175-
specs/02-API-ROUTES.md-3176-Non-breaking changes (new optional fields, new endpoints) can be added to existing versions. Breaking changes (removed fields, changed types, renamed endpoints) require version bumps.
specs/02-API-ROUTES.md-3177-
specs/02-API-ROUTES.md-3178----


exec

status: completed

input:
const r = await tools.exec_command({cmd:"rg -n \"analytics|export|api/apps|oauth|DispatchWebhooks|Event::listen|OrderCreated|checkout.completed|product.created|product.updated\" app routes tests database | head -300","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":20000});
text(r.output);


type: CommandExecution

command: rg -n "analytics|export|api/apps|oauth|DispatchWebhooks|Event::listen|OrderCreated|checkout.completed|product.created|product.updated" app routes tests database | head -300

type: search

cmd: rg -n 'analytics|export|api/apps|oauth|DispatchWebhooks|Event::listen|OrderCreated|checkout.completed|product.created|product.updated' app routes tests database

query: analytics|export|api/apps|oauth|DispatchWebhooks|Event::listen|OrderCreated|checkout.completed|product.created|product.updated

path: app

status: completed

stdout:
routes/api.php:30:    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics');
routes/api.php:77:    Route::get('analytics/summary', [AdminController::class, 'analyticsSummary']);
routes/web.php:102:Route::get('/oauth/authorize', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->middleware('auth')->name('oauth.authorize');
routes/web.php:103:Route::post('/oauth/token', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->name('oauth.token');
routes/web.php:134:    Route::livewire('/analytics', AdminAnalyticsIndex::class)->name('admin.analytics.index');
tests/Feature/PlatformApiAndMediaTest.php:137:    $this->postJson('http://shop.test/oauth/token')->assertStatus(501);
tests/Feature/SearchAnalyticsWebhookTest.php:45:test('analytics events aggregate idempotently into daily metrics', function (): void {
tests/Feature/SearchAnalyticsWebhookTest.php:47:    $analytics = new AnalyticsService;
tests/Feature/SearchAnalyticsWebhookTest.php:50:        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');
database/factories/AnalyticsDailyFactory.php:32:            'checkout_completed_count' => fake()->numberBetween(2, 8),
app/Livewire/Admin/Developers/Index.php:48:        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
app/Services/AnalyticsService.php:12:    private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'];
app/Services/AnalyticsService.php:17:            throw new \InvalidArgumentException('Unsupported analytics event type.');
app/Services/ProductService.php:48:            $this->audit->record('product.created', $product, ['store_id' => $store->getKey()]);
app/Services/ProductService.php:83:            $this->audit->record('product.updated', $product, ['store_id' => $product->store_id]);
app/Services/ProductService.php:107:        $this->audit->record('product.updated', $product, ['from_status' => $from->value, 'to_status' => $newStatus->value, 'store_id' => $product->store_id]);
app/Livewire/Admin/Analytics/Index.php:22:            'checkout_completed' => (int) $days->sum('checkout_completed_count'),
app/Livewire/Admin/Analytics/Index.php:25:        return view('livewire.admin.analytics.index', compact('days', 'summary'))->layout('layouts.admin');
database/factories/AnalyticsEventFactory.php:25:            'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']),
app/Services/OrderService.php:10:use App\Events\OrderCreated;
app/Services/OrderService.php:84:            OrderCreated::dispatch($order);
app/Events/OrderCreated.php:9:class OrderCreated
app/Livewire/Admin/Section.php:30:            request()->is('admin/analytics') => 'Analytics',
app/Livewire/Admin/Section.php:52:            request()->is('admin/analytics') => AnalyticsDaily::query()->latest('date')->take(30)->get()->map(fn (AnalyticsDaily $day): array => ['title' => $day->date->toDateString(), 'subtitle' => $day->visits_count.' visits', 'value' => $day->orders_count.' orders · €'.number_format($day->revenue_amount / 100, 2)])->all(),
app/Jobs/AggregateAnalytics.php:40:                    'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(),
app/Livewire/Admin/Dashboard.php:23:        $analytics = AnalyticsDaily::query()->whereBetween('date', [$from->toDateString(), $to->toDateString()])->orderBy('date')->get();
app/Livewire/Admin/Dashboard.php:26:        return view('livewire.admin.dashboard', ['orders' => $orders, 'sales' => $sales, 'orderCount' => $orderCount, 'productCount' => Product::query()->count(), 'analytics' => $analytics, 'topProducts' => $topProducts, 'visitors' => (int) $analytics->sum('visits_count'), 'addToCart' => (int) $analytics->sum('add_to_cart_count'), 'checkoutStarted' => (int) $analytics->sum('checkout_started_count')])->layout('layouts.admin');
app/Http/Controllers/Api/PlatformController.php:141:            'owner', 'admin' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'write-settings', 'read-customers', 'write-customers', 'read-analytics', 'read-content', 'write-content'],
app/Http/Controllers/Api/StorefrontAnalyticsController.php:12:    public function __construct(private readonly AnalyticsService $analytics) {}
app/Http/Controllers/Api/StorefrontAnalyticsController.php:32:            $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null);
app/Http/Controllers/Api/AdminController.php:344:    public function analyticsSummary(Request $request, int $storeId): JsonResponse
app/Http/Controllers/Api/AdminController.php:349:        return response()->json(['data' => ['visits' => (int) $days->sum('visits_count'), 'orders' => (int) $days->sum('orders_count'), 'revenue_amount' => (int) $days->sum('revenue_amount'), 'checkout_completed' => (int) $days->sum('checkout_completed_count'), 'days' => $days]]);
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:59:        Schema::table('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:60:            if (! Schema::hasColumn('oauth_clients', 'app_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:64:            if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:69:        Schema::table('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:70:            if (! Schema::hasColumn('oauth_tokens', 'installation_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:74:            if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:78:            if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:108:            'app_id' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:109:            'redirect_uris_json' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:110:            'installation_id' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:111:            'access_token_hash' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:112:            'refresh_token_hash' => 'oauth_tokens',
app/Models/AnalyticsDaily.php:12:    protected $table = 'analytics_daily';
app/Models/AnalyticsDaily.php:14:    protected $fillable = ['store_id', 'date', 'orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count'];
database/migrations/2026_08_20_000000_create_shop_schema.php:482:        Schema::create('analytics_events', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:496:        Schema::create('analytics_daily', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_000000_create_shop_schema.php:579:            'app_installations', 'apps', 'analytics_daily', 'analytics_events',
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:68:        Schema::table('analytics_events', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:69:            if (! Schema::hasColumn('analytics_events', 'properties_json')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:72:            if (! Schema::hasColumn('analytics_events', 'occurred_at')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:76:        Schema::table('analytics_daily', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:77:            if (! Schema::hasColumn('analytics_daily', 'checkout_completed_count')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:78:                $table->unsignedInteger('checkout_completed_count')->default(0);
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
app/Http/Middleware/EnsureApiAbility.php:56:        if (Str::contains($path, '/analytics')) {
app/Http/Middleware/EnsureApiAbility.php:57:            return 'read-analytics';
app/Providers/AppServiceProvider.php:58:        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));


text:
Script completed
Wall time 0.1 seconds
Output:

text:
routes/api.php:30:    Route::post('analytics/events', [StorefrontAnalyticsController::class, 'store'])->middleware('throttle:analytics');
routes/api.php:77:    Route::get('analytics/summary', [AdminController::class, 'analyticsSummary']);
routes/web.php:102:Route::get('/oauth/authorize', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->middleware('auth')->name('oauth.authorize');
routes/web.php:103:Route::post('/oauth/token', fn (): \Illuminate\Http\JsonResponse => response()->json(['message' => 'OAuth is not enabled in this deployment.'], 501))->name('oauth.token');
routes/web.php:134:    Route::livewire('/analytics', AdminAnalyticsIndex::class)->name('admin.analytics.index');
tests/Feature/PlatformApiAndMediaTest.php:137:    $this->postJson('http://shop.test/oauth/token')->assertStatus(501);
tests/Feature/SearchAnalyticsWebhookTest.php:45:test('analytics events aggregate idempotently into daily metrics', function (): void {
tests/Feature/SearchAnalyticsWebhookTest.php:47:    $analytics = new AnalyticsService;
tests/Feature/SearchAnalyticsWebhookTest.php:50:        $event = $analytics->track($this->store, $type, ['source' => 'test'], 'session-1');
database/factories/AnalyticsDailyFactory.php:32:            'checkout_completed_count' => fake()->numberBetween(2, 8),
app/Livewire/Admin/Developers/Index.php:48:        $allowed = ['manage-platform', 'read-products', 'write-products', 'read-orders', 'write-orders', 'read-customers', 'write-customers', 'read-collections', 'write-collections', 'read-discounts', 'write-discounts', 'read-analytics', 'read-settings', 'write-settings', 'read-themes', 'write-themes', 'read-content', 'write-content'];
app/Services/AnalyticsService.php:12:    private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'];
app/Services/AnalyticsService.php:17:            throw new \InvalidArgumentException('Unsupported analytics event type.');
app/Services/ProductService.php:48:            $this->audit->record('product.created', $product, ['store_id' => $store->getKey()]);
app/Services/ProductService.php:83:            $this->audit->record('product.updated', $product, ['store_id' => $product->store_id]);
app/Services/ProductService.php:107:        $this->audit->record('product.updated', $product, ['from_status' => $from->value, 'to_status' => $newStatus->value, 'store_id' => $product->store_id]);
app/Livewire/Admin/Analytics/Index.php:22:            'checkout_completed' => (int) $days->sum('checkout_completed_count'),
app/Livewire/Admin/Analytics/Index.php:25:        return view('livewire.admin.analytics.index', compact('days', 'summary'))->layout('layouts.admin');
database/factories/AnalyticsEventFactory.php:25:            'type' => fake()->randomElement(['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search']),
app/Services/OrderService.php:10:use App\Events\OrderCreated;
app/Services/OrderService.php:84:            OrderCreated::dispatch($order);
app/Events/OrderCreated.php:9:class OrderCreated
app/Livewire/Admin/Section.php:30:            request()->is('admin/analytics') => 'Analytics',
app/Livewire/Admin/Section.php:52:            request()->is('admin/analytics') => AnalyticsDaily::query()->latest('date')->take(30)->get()->map(fn (AnalyticsDaily $day): array => ['title' => $day->date->toDateString(), 'subtitle' => $day->visits_count.' visits', 'value' => $day->orders_count.' orders · €'.number_format($day->revenue_amount / 100, 2)])->all(),
app/Jobs/AggregateAnalytics.php:40:                    'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(),
app/Livewire/Admin/Dashboard.php:23:        $analytics = AnalyticsDaily::query()->whereBetween('date', [$from->toDateString(), $to->toDateString()])->orderBy('date')->get();
app/Livewire/Admin/Dashboard.php:26:        return view('livewire.admin.dashboard', ['orders' => $orders, 'sales' => $sales, 'orderCount' => $orderCount, 'productCount' => Product::query()->count(), 'analytics' => $analytics, 'topProducts' => $topProducts, 'visitors' => (int) $analytics->sum('visits_count'), 'addToCart' => (int) $analytics->sum('add_to_cart_count'), 'checkoutStarted' => (int) $analytics->sum('checkout_started_count')])->layout('layouts.admin');
app/Http/Controllers/Api/PlatformController.php:141:            'owner', 'admin' => ['read-products', 'write-products', 'read-orders', 'write-orders', 'read-settings', 'write-settings', 'read-customers', 'write-customers', 'read-analytics', 'read-content', 'write-content'],
app/Http/Controllers/Api/StorefrontAnalyticsController.php:12:    public function __construct(private readonly AnalyticsService $analytics) {}
app/Http/Controllers/Api/StorefrontAnalyticsController.php:32:            $stored[] = $this->analytics->track(app('current_store'), $event['type'], $event['properties'] ?? [], $event['session_id'] ?? ($request->hasSession() ? $request->session()->getId() : null), $request->user('customer')?->getKey(), $event['client_event_id'] ?? null, isset($event['occurred_at']) ? new \DateTimeImmutable($event['occurred_at']) : null);
app/Http/Controllers/Api/AdminController.php:344:    public function analyticsSummary(Request $request, int $storeId): JsonResponse
app/Http/Controllers/Api/AdminController.php:349:        return response()->json(['data' => ['visits' => (int) $days->sum('visits_count'), 'orders' => (int) $days->sum('orders_count'), 'revenue_amount' => (int) $days->sum('revenue_amount'), 'checkout_completed' => (int) $days->sum('checkout_completed_count'), 'days' => $days]]);
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:59:        Schema::table('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:60:            if (! Schema::hasColumn('oauth_clients', 'app_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:64:            if (! Schema::hasColumn('oauth_clients', 'redirect_uris_json')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:69:        Schema::table('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:70:            if (! Schema::hasColumn('oauth_tokens', 'installation_id')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:74:            if (! Schema::hasColumn('oauth_tokens', 'access_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:78:            if (! Schema::hasColumn('oauth_tokens', 'refresh_token_hash')) {
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:108:            'app_id' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:109:            'redirect_uris_json' => 'oauth_clients',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:110:            'installation_id' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:111:            'access_token_hash' => 'oauth_tokens',
database/migrations/2026_08_20_230144_add_remaining_spec_contract_fields.php:112:            'refresh_token_hash' => 'oauth_tokens',
app/Models/AnalyticsDaily.php:12:    protected $table = 'analytics_daily';
app/Models/AnalyticsDaily.php:14:    protected $fillable = ['store_id', 'date', 'orders_count', 'revenue_amount', 'aov_amount', 'visits_count', 'add_to_cart_count', 'checkout_started_count', 'checkout_completed_count'];
database/migrations/2026_08_20_000000_create_shop_schema.php:482:        Schema::create('analytics_events', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:496:        Schema::create('analytics_daily', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:528:        Schema::create('oauth_clients', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:538:        Schema::create('oauth_tokens', function (Blueprint $table): void {
database/migrations/2026_08_20_000000_create_shop_schema.php:540:            $table->foreignId('oauth_client_id')->constrained()->cascadeOnDelete();
database/migrations/2026_08_20_000000_create_shop_schema.php:578:            'webhook_deliveries', 'webhook_subscriptions', 'oauth_tokens', 'oauth_clients',
database/migrations/2026_08_20_000000_create_shop_schema.php:579:            'app_installations', 'apps', 'analytics_daily', 'analytics_events',
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:68:        Schema::table('analytics_events', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:69:            if (! Schema::hasColumn('analytics_events', 'properties_json')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:72:            if (! Schema::hasColumn('analytics_events', 'occurred_at')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:76:        Schema::table('analytics_daily', function (Blueprint $table): void {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:77:            if (! Schema::hasColumn('analytics_daily', 'checkout_completed_count')) {
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:78:                $table->unsignedInteger('checkout_completed_count')->default(0);
database/migrations/2026_08_20_223045_add_remaining_schema_contract_columns.php:113:        foreach (['password_hash' => 'users', 'store_id' => 'customer_password_reset_tokens', 'width' => 'product_media', 'height' => 'product_media', 'storage_key' => 'theme_files', 'sha256' => 'theme_files', 'byte_size' => 'theme_files', 'body_html' => 'pages', 'menu_id' => 'navigation_items', 'synonyms_json' => 'search_settings', 'stop_words_json' => 'search_settings', 'total_amount' => 'order_lines', 'currency' => 'payments', 'properties_json' => 'analytics_events', 'occurred_at' => 'analytics_events', 'checkout_completed_count' => 'analytics_daily', 'event_type' => 'webhook_subscriptions', 'app_installation_id' => 'webhook_subscriptions', 'subscription_id' => 'webhook_deliveries', 'event_id' => 'webhook_deliveries', 'attempt_count' => 'webhook_deliveries', 'last_attempt_at' => 'webhook_deliveries', 'response_code' => 'webhook_deliveries', 'response_body_snippet' => 'webhook_deliveries'] as $column => $tableName) {
app/Http/Middleware/EnsureApiAbility.php:56:        if (Str::contains($path, '/analytics')) {
app/Http/Middleware/EnsureApiAbility.php:57:            return 'read-analytics';
app/Providers/AppServiceProvider.php:58:        RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));


status: completed

input:
const r = await tools.exec_command({cmd:"sed -n '1,430p' app/Http/Controllers/Api/AdminController.php && sed -n '2825,2900p' specs/02-API-ROUTES.md && rg -n -C 5 \"supported event|Event Type|order.created|checkout.completed\" specs/05-BUSINESS-LOGIC.md","workdir":"/Users/wesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":30000});
text(r.output);


command: sed -n '1,430p' app/Http/Controllers/Api/AdminController.php && sed -n '2825,2900p' specs/02-API-ROUTES.md && rg -n -C 5 "supported event|Event Type|order.created|checkout.completed" specs/05-BUSINESS-LOGIC.md

AdminController.php

type: read

type: search

cmd: rg -n -C 5 'supported event|Event Type|order.created|checkout.completed' specs/05-BUSINESS-LOGIC.md

query: supported event|Event Type|order.created|checkout.completed

stdout:
<?php

namespace App\Http\Controllers\Api;

use App\Enums\ProductStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\CreateFulfillmentRequest;
use App\Http\Requests\CreateRefundRequest;
use App\Http\Requests\StoreCollectionRequest;
use App\Http\Requests\StoreDiscountRequest;
use App\Http\Requests\StorePageRequest;
use App\Http\Requests\StoreProductRequest;
use App\Http\Requests\StoreShippingRateRequest;
use App\Http\Requests\StoreShippingZoneRequest;
use App\Http\Requests\StoreThemeRequest;
use App\Http\Requests\UpdateCollectionRequest;
use App\Http\Requests\UpdateDiscountRequest;
use App\Http\Requests\UpdatePageRequest;
use App\Http\Requests\UpdateProductRequest;
use App\Http\Requests\UpdateShippingZoneRequest;
use App\Http\Requests\UpdateTaxSettingsRequest;
use App\Http\Requests\UpdateThemeSettingsRequest;
use App\Models\Collection;
use App\Models\Customer;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Page;
use App\Models\Product;
use App\Models\ShippingRate;
use App\Models\ShippingZone;
use App\Models\TaxSettings;
use App\Models\Theme;
use App\Services\FulfillmentService;
use App\Services\ProductService;
use App\Services\RefundService;
use App\Services\SearchService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;

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

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

    public function storeProduct(StoreProductRequest $request, int $storeId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $product = $products->create(app('current_store'), [...$data, 'status' => ProductStatus::from($data['status'] ?? ProductStatus::Draft->value)]);

        return response()->json(['data' => $product->load(['variants.inventory', 'variants.optionValues.option', 'options.values', 'media', 'collections'])->toArray()], 201);
    }

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

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

    public function updateProduct(UpdateProductRequest $request, int $storeId, int $productId, ProductService $products): JsonResponse
    {
        $this->assertStore($storeId);
        $product = Product::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($productId);
        $data = $request->validated();

        return response()->json(['data' => $products->update($product, $data)->load(['variants.inventory', 'variants.optionValues.option', 'options.values', 'media', 'collections'])->toArray()]);
    }

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

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

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

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

    public function storeCollection(StoreCollectionRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $collection = Collection::withoutGlobalScopes()->create(['store_id' => $storeId, 'title' => $data['title'], 'handle' => $data['handle'] ?? Str::slug($data['title']), 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? 'active']);
        $this->syncCollectionProducts($collection, $data['product_ids'] ?? [], $storeId);

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

    public function updateCollection(UpdateCollectionRequest $request, int $storeId, int $collectionId): JsonResponse
    {
        $this->assertStore($storeId);
        $collection = Collection::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($collectionId);
        $data = $request->validated();
        $collection->update(array_filter(['title' => $data['title'] ?? null, 'description' => $data['description_html'] ?? null, 'status' => $data['status'] ?? null], fn ($value): bool => $value !== null));

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

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

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

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

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

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

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

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

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

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

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

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

    public function storeDiscount(StoreDiscountRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $discount = Discount::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'status' => $data['status'] ?? 'active', 'usage_count' => 0]);

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

    public function updateDiscount(UpdateDiscountRequest $request, int $storeId, int $discountId): JsonResponse
    {
        $this->assertStore($storeId);
        $discount = Discount::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($discountId);
        $discount->update($request->validated());

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

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

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

    public function fulfillOrder(CreateFulfillmentRequest $request, int $storeId, int $orderId, FulfillmentService $fulfillments): JsonResponse
    {
        $this->assertStore($storeId);
        $order = Order::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($orderId);
        $data = $request->validated();
        $fulfillment = $fulfillments->create($order, $data['lines'], array_filter(['tracking_company' => $data['tracking_company'] ?? null, 'tracking_number' => $data['tracking_number'] ?? null, 'tracking_url' => $data['tracking_url'] ?? null]));

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

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

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

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

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

    public function storeShippingZone(StoreShippingZoneRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();

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

    public function updateShippingZone(UpdateShippingZoneRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        $zone = ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $zone->update($request->validated());

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

    public function storeShippingRate(StoreShippingRateRequest $request, int $storeId, int $zoneId): JsonResponse
    {
        $this->assertStore($storeId);
        ShippingZone::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($zoneId);
        $data = $request->validated();

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

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

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

    public function updateTaxSettings(UpdateTaxSettingsRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $settings = TaxSettings::withoutGlobalScopes()->updateOrCreate(['store_id' => $storeId], $data);

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

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

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

    public function storePage(StorePageRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $page = Page::withoutGlobalScopes()->create([...$data, 'store_id' => $storeId, 'handle' => $data['handle'] ?? Str::slug($data['title']), 'published_at' => $data['status'] === 'published' ? now() : null]);

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

    public function updatePage(UpdatePageRequest $request, int $storeId, int $pageId): JsonResponse
    {
        $this->assertStore($storeId);
        $page = Page::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($pageId);
        $data = $request->validated();
        if (($data['status'] ?? null) === 'published') {
            $data['published_at'] = $page->published_at ?? now();
        }
        $page->update($data);

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

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

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

    public function storeTheme(StoreThemeRequest $request, int $storeId): JsonResponse
    {
        $this->assertStore($storeId);
        $data = $request->validated();
        $theme = Theme::withoutGlobalScopes()->create(['store_id' => $storeId, 'name' => $data['name'], 'version' => $data['version'] ?? null, 'status' => 'draft']);
        $theme->settings()->create(['settings_json' => $data['settings'] ?? []]);

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

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

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

    public function updateThemeSettings(UpdateThemeSettingsRequest $request, int $storeId, int $themeId): JsonResponse
    {
        $this->assertStore($storeId);
        $theme = Theme::withoutGlobalScopes()->where('store_id', $storeId)->findOrFail($themeId);
        $theme->settings()->updateOrCreate(['theme_id' => $theme->getKey()], ['settings_json' => $request->validated()['settings']]);

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

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

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

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

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

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

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

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

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

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

**Error responses:**
- `403` - Insufficient permissions
- `422` - Invalid date range

---

#### POST /api/admin/v1/stores/{storeId}/exports/orders

Queue an order export job. The export runs asynchronously and produces a downloadable CSV.

**Request body:**

```json
{
  "format": "csv",
  "filters": {
    "status": "paid",
    "created_after": "2026-01-01T00:00:00Z",
    "created_before": "2026-02-11T23:59:59Z"
  }
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `format` | string | No | One of: `csv`. Default: `csv`. |
| `filters` | object | No | Same filter options as GET /orders. |

**Success response:** `202 Accepted`

```json
{
  "export_id": 1,
  "status": "queued",
  "created_at": "2026-02-11T10:00:00Z"
}
```

---

#### GET /api/admin/v1/stores/{storeId}/exports/{exportId}

Check export status and get download URL.

**Success response:** `200 OK`

```json
{
  "data": {
    "id": 1,
    "status": "completed",
    "format": "csv",
    "row_count": 156,
    "download_url": "https://s3.example.com/...presigned...",
    "download_expires_at": "2026-02-11T11:00:00Z",
    "created_at": "2026-02-11T10:00:00Z",
    "completed_at": "2026-02-11T10:01:30Z"
  }
}
```

**Export status values:** `queued`, `processing`, `completed`, `failed`.

**Error responses:**
- `403` - Insufficient permissions
- `404` - Export not found

---

## 4. Webhook Receiver Endpoints

> **Note:** No inbound webhook endpoints are needed. All payment processing happens in-process via the Mock PSP -- there are no external payment provider callbacks. The webhook infrastructure defined in this section is reserved for **outbound** app webhooks (see Section 5, OAuth / App Ecosystem).

237-
238-| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
239-|---|---|---|---|---|
240-| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
241-| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
242:| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
243-| **Restock** | Refund processed with restock flag | +quantity | No change | None |
244-
245-### 3.4 Concurrency
246-
247-SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
--
590-
591-| Event | Dispatched When |
592-|---|---|
593-| `CheckoutAddressed` | Address saved on checkout |
594-| `CheckoutShippingSelected` | Shipping method selected |
595:| `CheckoutCompleted` | Payment confirmed and order created |
596-| `CheckoutExpired` | Checkout expired due to inactivity |
597-
598----
599-
600-## 7. Discount Engine
--
1348-
1349-Stores (or app installations) subscribe to event types via `webhook_subscriptions`.
1350-
1351-**Supported event types:**
1352-
1353:| Event Type | Triggered By |
1354-|---|---|
1355:| `order.created` | OrderCreated event |
1356-| `order.paid` | Payment captured successfully |
1357-| `order.fulfilled` | All order lines fulfilled |
1358-| `order.refunded` | Refund processed |
1359-| `product.created` | New product saved |
1360-| `product.updated` | Product modified |
1361-| `product.deleted` | Product deleted or archived |
1362:| `checkout.completed` | Checkout successfully completed |
1363-
1364-### 13.2 Delivery Process
1365-
1366-**Listener:** `App\Listeners\DispatchWebhooks` - registered to listen for all domain events that have webhook counterparts.
1367-
--
1391-**Required HTTP headers on delivery:**
1392-
1393-| Header | Value |
1394-|---|---|
1395-| `X-Platform-Signature` | HMAC-SHA256 hex digest of the payload |
1396:| `X-Platform-Event` | Event type string (e.g., `order.created`) |
1397-| `X-Platform-Delivery-Id` | Unique UUID for this delivery attempt |
1398-| `X-Platform-Timestamp` | Unix timestamp of the event |
1399-| `Content-Type` | `application/json` |
1400-
1401-### 13.3 Retry Logic
--
1455-
1456-**Event fields:**
1457-
1458-| Field | Type | Required | Description |
1459-|---|---|---|---|
1460:| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |
1461-| `session_id` | string | Yes | Client session identifier |
1462-| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |
1463-| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |
1464-| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |
1465-
--
1476-
1477-**Aggregated metrics:**
1478-
1479-| Metric | Calculation |
1480-|---|---|
1481:| `orders_count` | Count of `checkout_completed` events for the day |
1482:| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
1483-| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
1484-| `visits_count` | Count of distinct `session_id` values with `page_view` events |
1485-| `add_to_cart_count` | Count of `add_to_cart` events |
1486-| `checkout_started_count` | Count of `checkout_started` events |
1487-
--
1589-
1590-Summary of all domain events and their listeners/side effects:
1591-
1592-| Event | Listeners / Side Effects |
1593-|---|---|
1594:| `OrderCreated` | Send confirmation email, dispatch webhooks (`order.created`), log analytics event |
1595-| `OrderRefunded` | Send refund notification email, dispatch webhooks (`order.refunded`), optionally restock |
1596-| `CheckoutAddressed` | Recalculate pricing |
1597-| `CheckoutShippingSelected` | Recalculate pricing |
1598:| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
1599-| `CheckoutExpired` | Release reserved inventory |
1600-| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |
1601-| `ProductCreated` | Sync FTS5 index, dispatch webhooks (`product.created`) |
1602-| `ProductUpdated` | Sync FTS5 index, dispatch webhooks (`product.updated`) |
1603-| `ProductDeleted` | Remove from FTS5 index, dispatch webhooks (`product.deleted`) |