Output:
Chunk ID: 1133da
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2131
Output:
### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)

**Priority:** HIGH - storefront and orders depend on this.

#### Step 2.1: Migrations (Batch 3-5)

Create in dependency order:

1. `create_products_table` - FK: `store_id`
2. `create_product_options_table` - FK: `product_id`
3. `create_product_option_values_table` - FK: `product_option_id`
4. `create_product_variants_table` - FK: `product_id`
5. `create_variant_option_values_table` - composite PK: `variant_id` + `product_option_value_id`
6. `create_inventory_items_table` - FK: `store_id`, `variant_id`
7. `create_collections_table` - FK: `store_id`
8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`
9. `create_product_media_table` - FK: `product_id`

#### Step 2.2: Models with Relationships

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Model (with migration, factory, seeder) | `Product` | Product entity |
| Model (with migration, factory, seeder) | `ProductOption` | Product option (e.g., Size, Color) |
| Model (with migration, factory, seeder) | `ProductOptionValue` | Option values (e.g., S, M, L) |
| Model (with migration, factory, seeder) | `ProductVariant` | Product variant (SKU-level) |
| Model (with migration, factory, seeder) | `InventoryItem` | Inventory tracking per variant |
| Model (with migration, factory, seeder) | `Collection` | Product collection/category |
| Model (with migration, factory, seeder) | `ProductMedia` | Product images and videos |

**Relationship definitions:**

| Model | Key Relationships |
|-------|-------------------|
| `Product` | `hasMany(ProductVariant)`, `hasMany(ProductOption)`, `hasMany(ProductMedia)`, `belongsToMany(Collection)` via `collection_products` |
| `ProductOption` | `belongsTo(Product)`, `hasMany(ProductOptionValue)` |
| `ProductOptionValue` | `belongsTo(ProductOption)` |
| `ProductVariant` | `belongsTo(Product)`, `hasOne(InventoryItem)`, `belongsToMany(ProductOptionValue)` via `variant_option_values` |
| `InventoryItem` | `belongsTo(ProductVariant)` |
| `Collection` | `belongsToMany(Product)` via `collection_products` |
| `ProductMedia` | `belongsTo(Product)` |

**Enums to create:**

| Enum | Values |
|------|--------|
| `ProductStatus` | `Draft`, `Active`, `Archived` |
| `VariantStatus` | `Active`, `Archived` |
| `CollectionStatus` | `Draft`, `Active`, `Archived` |
| `MediaType` | `Image`, `Video` |
| `MediaStatus` | `Processing`, `Ready`, `Failed` |
| `InventoryPolicy` | `Deny`, `Continue` |

#### Step 2.3: Product Service

**File to create:** `App\Services\ProductService`

| Method | Description |
|--------|-------------|
| `create(Store $store, array $data): Product` | Creates product with nested variants/options |
| `update(Product $product, array $data): Product` | Updates product |
| `transitionStatus(Product $product, ProductStatus $newStatus): void` | Validates transitions per state machine |
| `delete(Product $product): void` | Hard delete only if draft with no order references; otherwise throws |

**File to create:** `App\Services\VariantMatrixService`

| Method | Description |
|--------|-------------|
| `rebuildMatrix(Product $product): void` | Computes cartesian product, creates missing variants, archives orphaned ones |

**File to create:** `App\Support\HandleGenerator`

| Method | Description |
|--------|-------------|
| `generate(string $title, string $table, int $storeId, ?int $excludeId = null): string` | Generates unique slugs |

#### Step 2.4: Inventory Service

**File to create:** `App\Services\InventoryService`

| Method | Description |
|--------|-------------|
| `checkAvailability(InventoryItem $item, int $quantity): bool` | Checks if sufficient available inventory exists |
| `reserve(InventoryItem $item, int $quantity): void` | Increments `quantity_reserved` |
| `release(InventoryItem $item, int $quantity): void` | Decrements `quantity_reserved` |
| `commit(InventoryItem $item, int $quantity): void` | Decrements both `on_hand` and `reserved` |
| `restock(InventoryItem $item, int $quantity): void` | Increments `quantity_on_hand` |

All operations wrapped in database transactions. Throws `InsufficientInventoryException` when policy is `deny` and available < quantity.

#### Step 2.5: Media Upload

**File to create:** `App\Jobs\ProcessMediaUpload`

Requirements:
- Resizes images to standard sizes (thumbnail 150x150, medium 600x600, large 1200x1200)
- Updates `ProductMedia` status from `processing` to `ready` (or `failed`)

Livewire file upload via `WithFileUploads` trait. Stored on local `public` disk.

---

### Phase 3: Themes, Pages, Navigation, Storefront Layout

**Priority:** HIGH - storefront rendering depends on this.

#### Step 3.1: Migrations (Batch 3)

1. `create_themes_table` - FK: `store_id`
2. `create_theme_files_table` - FK: `theme_id`
3. `create_theme_settings_table` - PK: `theme_id`
4. `create_pages_table` - FK: `store_id`
5. `create_navigation_menus_table` - FK: `store_id`
6. `create_navigation_items_table` - FK: `menu_id`

#### Step 3.2: Models

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Model (with migration, factory, seeder) | `Theme` | Store theme entity |
| Model (with migration, factory, seeder) | `ThemeFile` | Individual theme file |
| Model (with migration, factory, seeder) | `ThemeSettings` | Theme configuration |
| Model (with migration, factory, seeder) | `Page` | CMS page |
| Model (with migration, factory, seeder) | `NavigationMenu` | Navigation menu entity |
| Model (with migration, factory, seeder) | `NavigationItem` | Individual navigation item |

**Relationship definitions:**

| Model | Key Relationships |
|-------|-------------------|
| `Theme` | `belongsTo(Store)`, `hasMany(ThemeFile)`, `hasOne(ThemeSettings)` |
| `ThemeFile` | `belongsTo(Theme)` |
| `ThemeSettings` | `belongsTo(Theme)` |
| `Page` | `belongsTo(Store)` |
| `NavigationMenu` | `belongsTo(Store)`, `hasMany(NavigationItem)` |
| `NavigationItem` | `belongsTo(NavigationMenu)` |

**Enums to create:**

| Enum | Values |
|------|--------|
| `ThemeStatus` | `Draft`, `Published` |
| `PageStatus` | `Draft`, `Published`, `Archived` |
| `NavigationItemType` | `Link`, `Page`, `Collection`, `Product` |

#### Step 3.3: Storefront Blade Layout

**Directory structure for `resources/views/storefront/`:**

| Directory | Files |
|-----------|-------|
| `layouts/` | `app.blade.php` |
| `pages/` | `home.blade.php`, `collection.blade.php`, `product.blade.php`, `cart.blade.php`, `search.blade.php`, `page.blade.php` |
| `checkout/` | `index.blade.php`, `confirmation.blade.php` |
| `account/` | `login.blade.php`, `register.blade.php`, `dashboard.blade.php` |
| `account/orders/` | `index.blade.php`, `show.blade.php` |
| `account/addresses/` | `index.blade.php` |
| `errors/` | `404.blade.php`, `503.blade.php` |
| `components/` | `product-card.blade.php`, `price.blade.php`, `badge.blade.php`, `quantity-selector.blade.php`, `address-form.blade.php`, `order-summary.blade.php`, `breadcrumbs.blade.php`, `pagination.blade.php` |

Base layout (`app.blade.php`): header with nav, announcement bar, main content, footer, cart drawer. All with dark mode support via `dark:` prefix.

**ThemeSettings Service:** Singleton registered in `AppServiceProvider`, loads and caches active theme settings for the current store.

#### Step 3.4: Storefront Livewire Components

**Files to create:**

| Component | Purpose |
|-----------|---------|
| `Storefront\Home` | Dynamic sections based on theme settings |
| `Storefront\Collections\Index` | All collections grid |
| `Storefront\Collections\Show` | Product grid with filters/sort/pagination |
| `Storefront\Products\Show` | Variant selection, image gallery, add-to-cart |
| `Storefront\Cart\Show` | Full cart page with line items and totals |
| `Storefront\CartDrawer` | Slide-out cart drawer (always in layout) |
| `Storefront\Search\Index` | Search results with filters |
| `Storefront\Search\Modal` | Search modal with autocomplete |
| `Storefront\Pages\Show` | Static CMS page rendering |

#### Step 3.5: Navigation Service

**File to create:** `App\Services\NavigationService`

| Method | Description |
|--------|-------------|
| `buildTree(NavigationMenu $menu): array` | Builds navigation trees from navigation_menus / navigation_items |
| `resolveUrl(NavigationItem $item): string` | Resolves URLs for page, collection, product type items |

Caches per store with 5-minute TTL.

---

### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes


sed -n '111,260p' specs/05-BUSINESS-LOGIC.md

## 2. Product Lifecycle

### 2.1 Status Transitions

**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`

```mermaid
stateDiagram-v2
    [*] --> Draft : Product created
    Draft --> Active : Publish (requires variant with price > 0, title not empty)
    Draft --> Archived : Archive
    Active --> Archived : Archive (hide from storefront)
    Archived --> Active : Re-publish (same preconditions as Draft->Active)
    Active --> Draft : Revert (BLOCKED if order_lines exist)
    Archived --> Draft : Revert (BLOCKED if order_lines exist)
```

**Transition rules:**

| From | To | Preconditions |
|---|---|---|
| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
| Draft | Archived | None |
| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
| Archived | Active | Same preconditions as Draft to Active |
| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |

**Deletion rules:**
- Only `draft` products with zero `order_lines` references may be hard-deleted.
- Products with existing order references must be archived, never deleted, to preserve order history integrity.

**Service:** `App\Services\ProductService`
- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
- On activation: set `published_at` to current time if not already set.
- Dispatch `ProductStatusChanged` event after successful transition.

### 2.2 Variant and Option Matrix

**Rules:**

- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
- Each option has 1 or more values, stored in `product_option_values`.
- Variants represent the cartesian product of option values (or a manually curated subset).
- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.

**Matrix rebuild logic (when options change):**

```
PROCEDURE rebuildMatrix(product):
    current_option_values = load all option values grouped by option
    desired_combos = cartesianProduct(current_option_values)
    existing_variants = load all variants with their option value sets

    FOR EACH combo IN desired_combos:
        IF combo matches an existing variant's option value set:
            SKIP (preserve existing variant - prices, SKUs, inventory intact)
        ELSE:
            CREATE new variant with default pricing from first existing variant

    FOR EACH existing_variant NOT matching any desired combo:
        IF variant has order_line references:
            ARCHIVE variant (set status = 'archived')
        ELSE:
            DELETE variant
```

**Service:** `App\Services\VariantMatrixService`

### 2.3 SKU Uniqueness

- Combination of store_id (derived through the product's store) and SKU must be unique.
- Enforced at application level before save.
- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.

### 2.4 Handle/Slug Generation

**Helper class:** `App\Support\HandleGenerator`

- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
- Merchants can manually edit the handle; on save, re-validate uniqueness.
- Same logic applies to collections and pages.

---

## 3. Inventory Management

### 3.1 Tracking Model

Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).

| Field | Description |
|---|---|
| `quantity_on_hand` | Total physical stock count |
| `quantity_reserved` | Units held for active checkouts or pending orders |
| **Available** (computed) | `quantity_on_hand - quantity_reserved` |

### 3.2 Inventory Policy

Per-variant policy stored in `inventory_items.policy`:

| Policy | Behavior |
|---|---|
| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |

### 3.3 Inventory Operations

**Service:** `App\Services\InventoryService`

All operations must execute within a database transaction.

```mermaid
flowchart LR
    subgraph "Stock Lifecycle"
        A[On Hand] -->|Reserve| B[Reserved]
        B -->|Commit on payment| C[Deducted]
        B -->|Release on expiry| A
        C -->|Restock on refund| A
    end
```

**Operation definitions:**

| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
|---|---|---|---|---|
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully


Output:
Chunk ID: d6cdb4
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1472
Output:
## 2. Product Lifecycle

### 2.1 Status Transitions

**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`

```mermaid
stateDiagram-v2
    [*] --> Draft : Product created
    Draft --> Active : Publish (requires variant with price > 0, title not empty)
    Draft --> Archived : Archive
    Active --> Archived : Archive (hide from storefront)
    Archived --> Active : Re-publish (same preconditions as Draft->Active)
    Active --> Draft : Revert (BLOCKED if order_lines exist)
    Archived --> Draft : Revert (BLOCKED if order_lines exist)
```

**Transition rules:**

| From | To | Preconditions |
|---|---|---|
| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
| Draft | Archived | None |
| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
| Archived | Active | Same preconditions as Draft to Active |
| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |

**Deletion rules:**
- Only `draft` products with zero `order_lines` references may be hard-deleted.
- Products with existing order references must be archived, never deleted, to preserve order history integrity.

**Service:** `App\Services\ProductService`
- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
- On activation: set `published_at` to current time if not already set.
- Dispatch `ProductStatusChanged` event after successful transition.

### 2.2 Variant and Option Matrix

**Rules:**

- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
- Each option has 1 or more values, stored in `product_option_values`.
- Variants represent the cartesian product of option values (or a manually curated subset).
- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.

**Matrix rebuild logic (when options change):**

```
PROCEDURE rebuildMatrix(product):
    current_option_values = load all option values grouped by option
    desired_combos = cartesianProduct(current_option_values)
    existing_variants = load all variants with their option value sets

    FOR EACH combo IN desired_combos:
        IF combo matches an existing variant's option value set:
            SKIP (preserve existing variant - prices, SKUs, inventory intact)
        ELSE:
            CREATE new variant with default pricing from first existing variant

    FOR EACH existing_variant NOT matching any desired combo:
        IF variant has order_line references:
            ARCHIVE variant (set status = 'archived')
        ELSE:
            DELETE variant
```

**Service:** `App\Services\VariantMatrixService`

### 2.3 SKU Uniqueness

- Combination of store_id (derived through the product's store) and SKU must be unique.
- Enforced at application level before save.
- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.

### 2.4 Handle/Slug Generation

**Helper class:** `App\Support\HandleGenerator`

- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
- Merchants can manually edit the handle; on save, re-validate uniqueness.
- Same logic applies to collections and pages.

---

## 3. Inventory Management

### 3.1 Tracking Model

Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).

| Field | Description |
|---|---|
| `quantity_on_hand` | Total physical stock count |
| `quantity_reserved` | Units held for active checkouts or pending orders |
| **Available** (computed) | `quantity_on_hand - quantity_reserved` |

### 3.2 Inventory Policy

Per-variant policy stored in `inventory_items.policy`:

| Policy | Behavior |
|---|---|
| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |

### 3.3 Inventory Operations

**Service:** `App\Services\InventoryService`

All operations must execute within a database transaction.

```mermaid
flowchart LR
    subgraph "Stock Lifecycle"
        A[On Hand] -->|Reserve| B[Reserved]
        B -->|Commit on payment| C[Deducted]
        B -->|Release on expiry| A
        C -->|Restock on refund| A
    end
```

**Operation definitions:**

| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
|---|---|---|---|---|
| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
| **Restock** | Refund processed with restock flag | +quantity | No change | None |

### 3.4 Concurrency

SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.

---

## 4. Cart Service

### 4.1 Cart Lifecycle

**Service:** `App\Services\CartService`

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully


Output:
Chunk ID: 3cca7d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 880
Output:
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

**Cart identification:**
- Guest users: cart ID stored in session cookie.
- Authenticated customers: cart linked via `customer_id` foreign key.

The guest cart is bound to the session using the key `cart_id`. When a guest visits the storefront, a cart is created on first add-to-cart and the cart's ID is stored in `session('cart_id')`. On customer login, if a session cart exists, it is merged with any existing customer cart (the session cart's items are added to the customer cart, preferring the higher quantity for duplicate variants). After merge, the session key is cleared.

**Cart merge on login:**

```
PROCEDURE mergeGuestCart(guestCart, customerCart):
    FOR EACH line IN guestCart.lines:
        existingLine = customerCart.findLineByVariant(line.variant_id)
        IF existingLine EXISTS:
            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
            RECALCULATE existingLine amounts
        ELSE:
            MOVE line to customerCart
    MARK guestCart as abandoned
    RECALCULATE all line amounts on customerCart
```

### 4.2 Cart Operations

**Create cart:**
- Accepts: store, optional customer
- Sets: store_id, currency from store's default_currency, initial cart_version = 1

**Add line - validations (in order):**

1. Variant exists and belongs to the current store (through its product).
2. Product has status = `active`.
3. Variant has status = `active`.
4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
6. Set `unit_price_amount` from the variant's `price_amount`.
7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
10. Increment `cart_version`.

**Update quantity:**
- Validate new quantity > 0. If 0, remove the line instead.
- Re-check inventory policy for the new quantity.
- Recalculate line amounts.
- Increment `cart_version`.

**Remove line:**
- Delete the `cart_lines` record.
- Increment `cart_version`.

### 4.3 Cart Version (Optimistic Concurrency)

Every cart mutation increments `cart_version` by 1.

**For API clients:**
- Client sends `expected_version` with each mutation request.
- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.
- This prevents race conditions when multiple browser tabs or devices modify the same cart.

**For Livewire/session-based flows:**
- Still implemented for consistency, but less critical since requests are serialized per session.

### 4.4 Line Amount Calculation

```
line_subtotal_amount = unit_price_amount * quantity
line_discount_amount = 0  (updated by discount engine during checkout)
line_total_amount    = line_subtotal_amount - line_discount_amount
```

All values are integers in minor currency units.

### 4.5 Cart Expiration

**Job:** `App\Jobs\CleanupAbandonedCarts`

- Schedule: daily
- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.
- Marks them as `abandoned`.
- Releases any reserved inventory associated with active checkouts for these carts.

---

## 5. Pricing Engine


sed -n '184,360p' specs/01-DATABASE-SCHEMA.md

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |
| value | TEXT | No | - | - | Display value, e.g. "Small" |
| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |

**Indexes:**

- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option
- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option

---

### product_variants

Purchasable SKU-level variant of a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| sku | TEXT | Yes | NULL | - | Stock keeping unit |
| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |
| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |
| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| weight_g | INTEGER | Yes | NULL | - | Weight in grams |
| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |
| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product
- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants
- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup
- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing
- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant

**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).

---

### variant_option_values

Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |
| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |

**Primary Key:** (`variant_id`, `product_option_value_id`)

**Indexes:**

- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value

**Notes:** This is a junction table that links variants to the specific option values they represent. Required to resolve which combination of options each variant corresponds to.

---

### inventory_items

Stock tracking per variant per store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |
| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |
| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |
| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |

**Indexes:**

- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup
- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store

**Notes:** `quantity_on_hand` is the total physical stock. `quantity_reserved` is stock allocated to open orders not yet fulfilled. Available = on_hand - reserved. Policy `deny` prevents overselling; `continue` allows selling below zero.

---

### collections

Curated or rule-based product groupings.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Collection display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store
- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status

---

### collection_products

Pivot linking products to collections with ordering. Composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |
| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |
| position | INTEGER | No | 0 | - | Display ordering within collection |

**Primary Key:** (`collection_id`, `product_id`)

**Indexes:**

- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product
- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection

---

### product_media

Images and videos attached to a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
| storage_key | TEXT | No | - | - | File storage path/key |
| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |
| width | INTEGER | Yes | NULL | - | Pixel width |
| height | INTEGER | Yes | NULL | - | Pixel height |
| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
| byte_size | INTEGER | Yes | NULL | - | File size in bytes |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**


Output:
Chunk ID: c94349
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1985
Output:

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_option_id | INTEGER | No | - | FK -> product_options(id) ON DELETE CASCADE | Parent option |
| value | TEXT | No | - | - | Display value, e.g. "Small" |
| position | INTEGER | No | 0 | UNIQUE per (product_option_id, position) | Display ordering |

**Indexes:**

- `idx_product_option_values_option_id` on (`product_option_id`) - FK lookup, listing values per option
- `idx_product_option_values_option_position` UNIQUE on (`product_option_id`, `position`) - Enforces unique ordering per option

---

### product_variants

Purchasable SKU-level variant of a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| sku | TEXT | Yes | NULL | - | Stock keeping unit |
| barcode | TEXT | Yes | NULL | - | UPC/EAN barcode |
| price_amount | INTEGER | No | 0 | - | Price in minor units (cents) |
| compare_at_amount | INTEGER | Yes | NULL | - | Original/compare price in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| weight_g | INTEGER | Yes | NULL | - | Weight in grams |
| requires_shipping | INTEGER | No | 1 | - | Boolean: physical product flag |
| is_default | INTEGER | No | 0 | - | Boolean: default variant flag |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_product_variants_product_id` on (`product_id`) - FK lookup, listing variants per product
- `idx_product_variants_sku` on (`sku`) - SKU lookup across all variants
- `idx_product_variants_barcode` on (`barcode`) - Barcode scanning lookup
- `idx_product_variants_product_position` on (`product_id`, `position`) - Ordered variant listing
- `idx_product_variants_product_default` on (`product_id`, `is_default`) - Quick lookup of default variant

**Notes:** `price_amount` and `compare_at_amount` are in minor currency units (cents). A product always has at least one variant (the default).

---

### variant_option_values

Maps a variant to its chosen option values (e.g. variant #5 = Size:Large + Color:Red). Junction table with composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| variant_id | INTEGER | No | - | PK (composite), FK -> product_variants(id) ON DELETE CASCADE | |
| product_option_value_id | INTEGER | No | - | PK (composite), FK -> product_option_values(id) ON DELETE CASCADE | |

**Primary Key:** (`variant_id`, `product_option_value_id`)

**Indexes:**

- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value

**Notes:** This is a junction table that links variants to the specific option values they represent. Required to resolve which combination of options each variant corresponds to.

---

### inventory_items

Stock tracking per variant per store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE, UNIQUE | One inventory record per variant |
| quantity_on_hand | INTEGER | No | 0 | - | Total physical stock |
| quantity_reserved | INTEGER | No | 0 | - | Stock allocated to open orders |
| policy | TEXT | No | 'deny' | CHECK IN ('deny', 'continue') | Oversell policy |

**Indexes:**

- `idx_inventory_items_variant_id` UNIQUE on (`variant_id`) - Enforces one inventory record per variant, fast variant lookup
- `idx_inventory_items_store_id` on (`store_id`) - Listing all inventory for a store

**Notes:** `quantity_on_hand` is the total physical stock. `quantity_reserved` is stock allocated to open orders not yet fulfilled. Available = on_hand - reserved. Policy `deny` prevents overselling; `continue` allows selling below zero.

---

### collections

Curated or rule-based product groupings.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Collection display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| type | TEXT | No | 'manual' | CHECK IN ('manual', 'automated') | Collection type: manual curation or rule-based automation |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_collections_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_collections_store_id` on (`store_id`) - FK lookup, listing all collections in a store
- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status

---

### collection_products

Pivot linking products to collections with ordering. Composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| collection_id | INTEGER | No | - | PK (composite), FK -> collections(id) ON DELETE CASCADE | |
| product_id | INTEGER | No | - | PK (composite), FK -> products(id) ON DELETE CASCADE | |
| position | INTEGER | No | 0 | - | Display ordering within collection |

**Primary Key:** (`collection_id`, `product_id`)

**Indexes:**

- `idx_collection_products_product_id` on (`product_id`) - Reverse lookup: find all collections containing a product
- `idx_collection_products_position` on (`collection_id`, `position`) - Ordered product listing within a collection

---

### product_media

Images and videos attached to a product.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| type | TEXT | No | 'image' | CHECK IN ('image', 'video') | Media type |
| storage_key | TEXT | No | - | - | File storage path/key |
| alt_text | TEXT | Yes | NULL | - | Accessibility alt text |
| width | INTEGER | Yes | NULL | - | Pixel width |
| height | INTEGER | Yes | NULL | - | Pixel height |
| mime_type | TEXT | Yes | NULL | - | e.g. "image/jpeg" |
| byte_size | INTEGER | Yes | NULL | - | File size in bytes |
| position | INTEGER | No | 0 | - | Display ordering |
| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**


sed -n '360,470p' specs/01-DATABASE-SCHEMA.md

- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
- `idx_product_media_status` on (`status`) - Finding media by processing status

---

## Epic 3 - Themes, Storefront, and Localization

### themes

Storefront theme definitions per store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| name | TEXT | No | - | - | Theme display name |
| version | TEXT | Yes | NULL | - | Semantic version string |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store
- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---

### navigation_menus

Named menus (e.g. main-menu, footer-menu).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. "main-menu" |
| title | TEXT | No | - | - | Display title |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store
- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store

---

### navigation_items

Individual links within a navigation menu (supports nesting via position).


Output:
Chunk ID: 329a61
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1041
Output:

- `idx_product_media_product_id` on (`product_id`) - FK lookup, listing media per product
- `idx_product_media_product_position` on (`product_id`, `position`) - Ordered media listing
- `idx_product_media_status` on (`status`) - Finding media by processing status

---

## Epic 3 - Themes, Storefront, and Localization

### themes

Storefront theme definitions per store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| name | TEXT | No | - | - | Theme display name |
| version | TEXT | Yes | NULL | - | Semantic version string |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_themes_store_id` on (`store_id`) - FK lookup, listing themes per store
- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store

---

### theme_files

Individual files belonging to a theme (templates, assets, snippets).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| theme_id | INTEGER | No | - | FK -> themes(id) ON DELETE CASCADE | Parent theme |
| path | TEXT | No | - | UNIQUE per (theme_id, path) | File path within theme |
| storage_key | TEXT | No | - | - | File storage path/key |
| sha256 | TEXT | No | - | - | Content hash for cache busting |
| byte_size | INTEGER | No | 0 | - | File size in bytes |

**Indexes:**

- `idx_theme_files_theme_path` UNIQUE on (`theme_id`, `path`) - Enforces unique file paths per theme
- `idx_theme_files_theme_id` on (`theme_id`) - FK lookup, listing files per theme

---

### theme_settings

Single-row JSON configuration per theme.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| theme_id | INTEGER | No | - | PRIMARY KEY, FK -> themes(id) ON DELETE CASCADE | One-to-one with themes |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### pages

Static CMS pages (About Us, Contact, etc.).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_pages_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_pages_store_id` on (`store_id`) - FK lookup, listing pages per store
- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status

---

### navigation_menus

Named menus (e.g. main-menu, footer-menu).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | e.g. "main-menu" |
| title | TEXT | No | - | - | Display title |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_navigation_menus_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store
- `idx_navigation_menus_store_id` on (`store_id`) - FK lookup, listing menus per store

---

### navigation_items

Individual links within a navigation menu (supports nesting via position).


sed -n '140,210p' specs/01-DATABASE-SCHEMA.md


sed -n '140,210p' specs/01-DATABASE-SCHEMA.md

Single-row-per-store JSON settings bag.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products

Core product record, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Product display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |
| product_type | TEXT | Yes | NULL | - | Category classification |
| tags | TEXT | No | '[]' | - | JSON array, e.g. ["summer","sale"] |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store
- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date
- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor
- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type

**Notes:** `tags` is a JSON-encoded array (e.g. `["summer","sale"]`). The `handle` is unique per store, not globally.

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|


Output:
Chunk ID: 77108d
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 765
Output:

Single-row-per-store JSON settings bag.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| settings_json | TEXT | No | '{}' | - | JSON settings object |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Notes:** No additional indexes needed - primary key on `store_id` covers all queries.

---

## Epic 2 - Catalog, Collections, Inventory, and Media

### products

Core product record, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| title | TEXT | No | - | - | Product display title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
| description_html | TEXT | Yes | NULL | - | Rich-text description |
| vendor | TEXT | Yes | NULL | - | Manufacturer/brand |
| product_type | TEXT | Yes | NULL | - | Category classification |
| tags | TEXT | No | '[]' | - | JSON array, e.g. ["summer","sale"] |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; null = unpublished |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_products_store_handle` UNIQUE on (`store_id`, `handle`) - Unique handle per store for URL routing
- `idx_products_store_id` on (`store_id`) - FK lookup, listing all products in a store
- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
- `idx_products_published_at` on (`store_id`, `published_at`) - Sorting/filtering by publication date
- `idx_products_vendor` on (`store_id`, `vendor`) - Filtering products by vendor
- `idx_products_product_type` on (`store_id`, `product_type`) - Filtering products by type

**Notes:** `tags` is a JSON-encoded array (e.g. `["summer","sale"]`). The `handle` is unique per store, not globally.

---

### product_options

Named option dimensions for a product (e.g. Size, Color).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| product_id | INTEGER | No | - | FK -> products(id) ON DELETE CASCADE | Parent product |
| name | TEXT | No | - | - | Option name, e.g. "Size", "Color" |
| position | INTEGER | No | 0 | UNIQUE per (product_id, position) | Display ordering |

**Indexes:**

- `idx_product_options_product_id` on (`product_id`) - FK lookup, listing options per product
- `idx_product_options_product_position` UNIQUE on (`product_id`, `position`) - Enforces unique ordering per product

---

### product_option_values

Individual values within a product option (e.g. "Small", "Medium", "Large").

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|


Output:
Chunk ID: c3f1b7
Wall time: 0.0594 seconds
Process exited with code 0
Original token count: 864
Output:
Laravel Framework 12.51.0

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display help for the given command. When no command is given display help for the list command
      --silent          Do not output any message
  -q, --quiet           Only errors are displayed. All other output is suppressed
  -V, --version         Display this application version
      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
  -n, --no-interaction  Do not ask any interactive question
      --env[=ENV]       The environment the command should run under
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands for the "make" namespace:
  make:cache-table          [cache:table] Create a migration for the cache database table
  make:cast                 Create a new custom Eloquent cast class
  make:channel              Create a new channel class
  make:class                Create a new class
  make:command              Create a new Artisan command
  make:component            Create a new view component class
  make:config               [config:make] Create a new configuration file
  make:controller           Create a new controller class
  make:enum                 Create a new enum
  make:event                Create a new event class
  make:exception            Create a new custom exception class
  make:factory              Create a new model factory
  make:interface            Create a new interface
  make:job                  Create a new job class
  make:job-middleware       Create a new job middleware class
  make:listener             Create a new event listener class
  make:livewire             Create a new Livewire component
  make:mail                 Create a new email class
  make:mcp-app-resource     Create a new MCP app resource class and linked view
  make:mcp-prompt           Create a new MCP prompt class
  make:mcp-resource         Create a new MCP resource class
  make:mcp-server           Create a new MCP server class
  make:mcp-tool             Create a new MCP tool class
  make:middleware           Create a new HTTP middleware class
  make:migration            Create a new migration file
  make:model                Create a new Eloquent model class
  make:notification         Create a new notification class
  make:notifications-table  [notifications:table] Create a migration for the notifications table
  make:observer             Create a new observer class
  make:policy               Create a new policy class
  make:provider             Create a new service provider class
  make:queue-batches-table  [queue:batches-table] Create a migration for the batches database table
  make:queue-failed-table   [queue:failed-table] Create a migration for the failed queue jobs database table
  make:queue-table          [queue:table] Create a migration for the queue jobs database table
  make:request              Create a new form request class
  make:resource             Create a new resource
  make:rule                 Create a new validation rule
  make:scope                Create a new scope class
  make:seeder               Create a new seeder class
  make:session-table        [session:table] Create a migration for the session database table
  make:test                 Create a new test class
  make:trait                Create a new trait
  make:view                 Create a new view


/bin/zsh
-lc
php artisan list make | sed -n '1,220p'

Laravel Framework 12.51.0

Usage:
  command [options] [arguments]

Options:
  -h, --help            Display help for the given command. When no command is given display help for the list command
      --silent          Do not output any message
  -q, --quiet           Only errors are displayed. All other output is suppressed
  -V, --version         Display this application version
      --ansi|--no-ansi  Force (or disable --no-ansi) ANSI output
  -n, --no-interaction  Do not ask any interactive question
      --env[=ENV]       The environment the command should run under
  -v|vv|vvv, --verbose  Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug

Available commands for the "make" namespace:
  make:cache-table          [cache:table] Create a migration for the cache database table
  make:cast                 Create a new custom Eloquent cast class
  make:channel              Create a new channel class
  make:class                Create a new class
  make:command              Create a new Artisan command
  make:component            Create a new view component class
  make:config               [config:make] Create a new configuration file
  make:controller           Create a new controller class
  make:enum                 Create a new enum
  make:event                Create a new event class
  make:exception            Create a new custom exception class
  make:factory              Create a new model factory
  make:interface            Create a new interface
  make:job                  Create a new job class
  make:job-middleware       Create a new job middleware class
  make:listener             Create a new event listener class
  make:livewire             Create a new Livewire component
  make:mail                 Create a new email class
  make:mcp-app-resource     Create a new MCP app resource class and linked view
  make:mcp-prompt           Create a new MCP prompt class
  make:mcp-resource         Create a new MCP resource class
  make:mcp-server           Create a new MCP server class
  make:mcp-tool             Create a new MCP tool class
  make:middleware           Create a new HTTP middleware class
  make:migration            Create a new migration file
  make:model                Create a new Eloquent model class
  make:notification         Create a new notification class
  make:notifications-table  [notifications:table] Create a migration for the notifications table
  make:observer             Create a new observer class
  make:policy               Create a new policy class
  make:provider             Create a new service provider class
  make:queue-batches-table  [queue:batches-table] Create a migration for the batches database table
  make:queue-failed-table   [queue:failed-table] Create a migration for the failed queue jobs database table
  make:queue-table          [queue:table] Create a migration for the queue jobs database table
  make:request              Create a new form request class
  make:resource             Create a new resource
  make:rule                 Create a new validation rule
  make:scope                Create a new scope class
  make:seeder               Create a new seeder class
  make:session-table        [session:table] Create a migration for the session database table
  make:test                 Create a new test class
  make:trait                Create a new trait
  make:view                 Create a new view


While the schema/model worker runs, I’m implementing the non-overlapping Phase 2 service layer: handle generation, inventory operations, product status transitions, and variant matrix rebuild logic. These files depend on the catalog models by name but don’t touch the worker’s write scope.


php artisan make:class Support/HandleGenerator --no-interaction && php artisan make:class Services/ProductService --no-interaction && php artisan make:class Services/InventoryService --no-interaction && php artisan make:class Services/VariantMatrixService --no-interaction && php artisan make:exception InvalidProductTransitionException --no-interaction && php artisan make:exception InsufficientInventoryException --no-interaction && php artisan make:event ProductStatusChanged --no-interaction && php artisan make:job ProcessMediaUpload --no-interaction


Output:
Chunk ID: 66e373
Wall time: 1.0013 seconds
Process running with session ID 78345
Original token count: 165
Output:

   INFO  Class [app/Support/HandleGenerator.php] created successfully.

   INFO  Class [app/Services/ProductService.php] created successfully.

   INFO  Class [app/Services/InventoryService.php] created successfully.

   INFO  Class [app/Services/VariantMatrixService.php] created successfully.

   INFO  Exception [app/Exceptions/InvalidProductTransitionException.php] created successfully.

   INFO  Exception [app/Exceptions/InsufficientInventoryException.php] created successfully.

   INFO  Event [app/Events/ProductStatusChanged.php] created successfully.

   INFO  Job [app/Jobs/ProcessMediaUpload.php] created successfully.


php artisan make:class Support/HandleGenerator --no-interaction && php artisan make:class Services/ProductService --no-interaction && php artisan make:class Services/InventoryService --no-interaction && php artisan make:class Services/VariantMatrixService --no-interaction && php artisan make:exception InvalidProductTransitionException --no-interaction && php artisan make:exception InsufficientInventoryException --no-interaction && php artisan make:event ProductStatusChanged --no-interaction && php artisan make:job ProcessMediaUpload --no-interaction

   INFO  Class [app/Support/HandleGenerator.php] created successfully.

   INFO  Class [app/Services/ProductService.php] created successfully.

   INFO  Class [app/Services/InventoryService.php] created successfully.

   INFO  Class [app/Services/VariantMatrixService.php] created successfully.

   INFO  Exception [app/Exceptions/InvalidProductTransitionException.php] created successfully.

   INFO  Exception [app/Exceptions/InsufficientInventoryException.php] created successfully.

   INFO  Event [app/Events/ProductStatusChanged.php] created successfully.

   INFO  Job [app/Jobs/ProcessMediaUpload.php] created successfully.


Output:
Chunk ID: 2662ad
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 0
Output:


sed -n '1,120p' app/Support/HandleGenerator.php && sed -n '1,80p' app/Services/ProductService.php && sed -n '1,80p' app/Jobs/ProcessMediaUpload.php


sed -n '1,120p' app/Events/ProductStatusChanged.php && sed -n '1,80p' app/Exceptions/InvalidProductTransitionException.php


sed -n '1,120p' app/Events/ProductStatusChanged.php && sed -n '1,80p' app/Exceptions/InvalidProductTransitionException.php

InvalidProductTransitionException.php

<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('channel-name'),
        ];
    }
}
<?php

namespace App\Exceptions;

use Exception;

class InvalidProductTransitionException extends Exception
{
    //
}


sed -n '1,120p' app/Support/HandleGenerator.php && sed -n '1,80p' app/Services/ProductService.php && sed -n '1,80p' app/Jobs/ProcessMediaUpload.php

ProcessMediaUpload.php

<?php

namespace App\Support;

class HandleGenerator
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}
<?php

namespace App\Services;

class ProductService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        //
    }
}


Output:
Chunk ID: 1dbafb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 182
Output:
<?php

namespace App\Support;

class HandleGenerator
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}
<?php

namespace App\Services;

class ProductService
{
    /**
     * Create a new class instance.
     */
    public function __construct()
    {
        //
    }
}
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        //
    }
}


Output:
Chunk ID: fd48c7
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 235
Output:
<?php

namespace App\Events;

use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class ProductStatusChanged
{
    use Dispatchable, InteractsWithSockets, SerializesModels;

    /**
     * Create a new event instance.
     */
    public function __construct()
    {
        //
    }

    /**
     * Get the channels the event should broadcast on.
     *
     * @return array<int, \Illuminate\Broadcasting\Channel>
     */
    public function broadcastOn(): array
    {
        return [
            new PrivateChannel('channel-name'),
        ];
    }
}
<?php

namespace App\Exceptions;

use Exception;

class InvalidProductTransitionException extends Exception
{
    //
}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Support/HandleGenerator.php
@@
 namespace App\Support;

+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
 class HandleGenerator
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
     {
-        //
+        $base = Str::slug($title);
+        $base = $base === '' ? 'item' : $base;
+
+        $handle = $base;
+        $suffix = 1;
+
+        while ($this->exists($table, $storeId, $handle, $excludeId)) {
+            $handle = "{$base}-{$suffix}";
+            $suffix++;
+        }
+
+        return $handle;
+    }
+
+    private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool
+    {
+        $query = DB::table($table)
+            ->where('store_id', $storeId)
+            ->where('handle', $handle);
+
+        if ($excludeId !== null) {
+            $query->where('id', '!=', $excludeId);
+        }
+
+        return $query->exists();
     }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidProductTransitionException.php
@@
 namespace App\Exceptions;

 use Exception;

 class InvalidProductTransitionException extends Exception
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Exceptions/InsufficientInventoryException.php
@@
 namespace App\Exceptions;

 use Exception;

 class InsufficientInventoryException extends Exception
 {
-    //
+    public static function forQuantity(int $available, int $requested): self
+    {
+        return new self("Only {$available} units are available; {$requested} requested.");
+    }
 }
*** Update File: /Users/fabianwesner/Herd/shop/app/Events/ProductStatusChanged.php
@@
 namespace App\Events;

-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\Enums\ProductStatus;
+use App\Models\Product;
 use Illuminate\Foundation\Events\Dispatchable;
 use Illuminate\Queue\SerializesModels;

 class ProductStatusChanged
 {
-    use Dispatchable, InteractsWithSockets, SerializesModels;
+    use Dispatchable, SerializesModels;

-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
-
-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
+    public function __construct(
+        public Product $product,
+        public ProductStatus $from,
+        public ProductStatus $to,
+    ) {}
 }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Support/HandleGenerator.php
M /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidProductTransitionException.php
M /Users/fabianwesner/Herd/shop/app/Exceptions/InsufficientInventoryException.php
M /Users/fabianwesner/Herd/shop/app/Events/ProductStatusChanged.php

@@ -4,7 +4,4 @@

-use Illuminate\Broadcasting\Channel;
-use Illuminate\Broadcasting\InteractsWithSockets;
-use Illuminate\Broadcasting\PresenceChannel;
-use Illuminate\Broadcasting\PrivateChannel;
-use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use App\Enums\ProductStatus;
+use App\Models\Product;
 use Illuminate\Foundation\Events\Dispatchable;
@@ -14,23 +11,9 @@
 {
-    use Dispatchable, InteractsWithSockets, SerializesModels;
-
-    /**
-     * Create a new event instance.
-     */
-    public function __construct()
-    {
-        //
-    }
+    use Dispatchable, SerializesModels;

-    /**
-     * Get the channels the event should broadcast on.
-     *
-     * @return array<int, \Illuminate\Broadcasting\Channel>
-     */
-    public function broadcastOn(): array
-    {
-        return [
-            new PrivateChannel('channel-name'),
-        ];
-    }
+    public function __construct(
+        public Product $product,
+        public ProductStatus $from,
+        public ProductStatus $to,
+    ) {}
 }

@@ -8,3 +8,6 @@
 {
-    //
+    public static function forQuantity(int $available, int $requested): self
+    {
+        return new self("Only {$available} units are available; {$requested} requested.");
+    }
 }

@@ -8,3 +8,6 @@
 {
-    //
+    public static function because(string $reason): self
+    {
+        return new self($reason);
+    }
 }

@@ -4,10 +4,34 @@

+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
 class HandleGenerator
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
+    {
+        $base = Str::slug($title);
+        $base = $base === '' ? 'item' : $base;
+
+        $handle = $base;
+        $suffix = 1;
+
+        while ($this->exists($table, $storeId, $handle, $excludeId)) {
+            $handle = "{$base}-{$suffix}";
+            $suffix++;
+        }
+
+        return $handle;
+    }
+
+    private function exists(string $table, int $storeId, string $handle, ?int $excludeId): bool
     {
-        //
+        $query = DB::table($table)
+            ->where('store_id', $storeId)
+            ->where('handle', $handle);
+
+        if ($excludeId !== null) {
+            $query->where('id', '!=', $excludeId);
+        }
+
+        return $query->exists();
     }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Support/HandleGenerator.php\nM /Users/fabianwesner/Herd/shop/app/Exceptions/InvalidProductTransitionException.php\nM /Users/fabianwesner/Herd/shop/app/Exceptions/InsufficientInventoryException.php\nM /Users/fabianwesner/Herd/shop/app/Events/ProductStatusChanged.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php
@@
 namespace App\Services;

+use App\Enums\InventoryPolicy;
+use App\Exceptions\InsufficientInventoryException;
+use App\Models\InventoryItem;
+use Illuminate\Support\Facades\DB;
+use InvalidArgumentException;
+
 class InventoryService
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public function checkAvailability(InventoryItem $item, int $quantity): bool
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        if ($item->policy === InventoryPolicy::Continue) {
+            return true;
+        }
+
+        return $this->available($item) >= $quantity;
+    }
+
+    public function reserve(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if (! $this->checkAvailability($locked, $quantity)) {
+                throw InsufficientInventoryException::forQuantity($this->available($locked), $quantity);
+            }
+
+            $locked->forceFill([
+                'quantity_reserved' => $locked->quantity_reserved + $quantity,
+            ])->save();
+        });
+    }
+
+    public function release(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if ($locked->quantity_reserved < $quantity) {
+                throw new InvalidArgumentException('Cannot release more inventory than is reserved.');
+            }
+
+            $locked->forceFill([
+                'quantity_reserved' => $locked->quantity_reserved - $quantity,
+            ])->save();
+        });
+    }
+
+    public function commit(InventoryItem $item, int $quantity): void
     {
-        //
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if ($locked->quantity_reserved < $quantity) {
+                throw new InvalidArgumentException('Cannot commit more inventory than is reserved.');
+            }
+
+            $locked->forceFill([
+                'quantity_on_hand' => $locked->quantity_on_hand - $quantity,
+                'quantity_reserved' => $locked->quantity_reserved - $quantity,
+            ])->save();
+        });
+    }
+
+    public function restock(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            $locked->forceFill([
+                'quantity_on_hand' => $locked->quantity_on_hand + $quantity,
+            ])->save();
+        });
+    }
+
+    private function available(InventoryItem $item): int
+    {
+        return $item->quantity_on_hand - $item->quantity_reserved;
+    }
+
+    private function freshItem(InventoryItem $item): InventoryItem
+    {
+        return InventoryItem::query()
+            ->whereKey($item->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function assertPositiveQuantity(int $quantity): void
+    {
+        if ($quantity <= 0) {
+            throw new InvalidArgumentException('Inventory quantity must be greater than zero.');
+        }
     }
 }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php

@@ -4,10 +4,104 @@

+use App\Enums\InventoryPolicy;
+use App\Exceptions\InsufficientInventoryException;
+use App\Models\InventoryItem;
+use Illuminate\Support\Facades\DB;
+use InvalidArgumentException;
+
 class InventoryService
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public function checkAvailability(InventoryItem $item, int $quantity): bool
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        if ($item->policy === InventoryPolicy::Continue) {
+            return true;
+        }
+
+        return $this->available($item) >= $quantity;
+    }
+
+    public function reserve(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if (! $this->checkAvailability($locked, $quantity)) {
+                throw InsufficientInventoryException::forQuantity($this->available($locked), $quantity);
+            }
+
+            $locked->forceFill([
+                'quantity_reserved' => $locked->quantity_reserved + $quantity,
+            ])->save();
+        });
+    }
+
+    public function release(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if ($locked->quantity_reserved < $quantity) {
+                throw new InvalidArgumentException('Cannot release more inventory than is reserved.');
+            }
+
+            $locked->forceFill([
+                'quantity_reserved' => $locked->quantity_reserved - $quantity,
+            ])->save();
+        });
+    }
+
+    public function commit(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            if ($locked->quantity_reserved < $quantity) {
+                throw new InvalidArgumentException('Cannot commit more inventory than is reserved.');
+            }
+
+            $locked->forceFill([
+                'quantity_on_hand' => $locked->quantity_on_hand - $quantity,
+                'quantity_reserved' => $locked->quantity_reserved - $quantity,
+            ])->save();
+        });
+    }
+
+    public function restock(InventoryItem $item, int $quantity): void
+    {
+        $this->assertPositiveQuantity($quantity);
+
+        DB::transaction(function () use ($item, $quantity): void {
+            $locked = $this->freshItem($item);
+
+            $locked->forceFill([
+                'quantity_on_hand' => $locked->quantity_on_hand + $quantity,
+            ])->save();
+        });
+    }
+
+    private function available(InventoryItem $item): int
+    {
+        return $item->quantity_on_hand - $item->quantity_reserved;
+    }
+
+    private function freshItem(InventoryItem $item): InventoryItem
+    {
+        return InventoryItem::query()
+            ->whereKey($item->getKey())
+            ->lockForUpdate()
+            ->firstOrFail();
+    }
+
+    private function assertPositiveQuantity(int $quantity): void
     {
-        //
+        if ($quantity <= 0) {
+            throw new InvalidArgumentException('Inventory quantity must be greater than zero.');
+        }
     }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/InventoryService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}


Input:
*** Begin Patch
*** Update File: /Users/fabianwesner/Herd/shop/app/Services/ProductService.php
@@
 namespace App\Services;

+use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
+use App\Events\ProductStatusChanged;
+use App\Exceptions\InvalidProductTransitionException;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Support\HandleGenerator;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use RuntimeException;
+
 class ProductService
 {
-    /**
-     * Create a new class instance.
-     */
-    public function __construct()
+    public function __construct(
+        private readonly HandleGenerator $handles,
+    ) {}
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public function create(Store $store, array $data): Product
+    {
+        return DB::transaction(function () use ($store, $data): Product {
+            $product = Product::query()->create([
+                ...Arr::only($data, [
+                    'title',
+                    'status',
+                    'description_html',
+                    'vendor',
+                    'product_type',
+                    'tags',
+                    'published_at',
+                ]),
+                'store_id' => $store->getKey(),
+                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
+            ]);
+
+            foreach ($data['options'] ?? [] as $optionData) {
+                $option = $product->options()->create(Arr::only($optionData, ['name', 'position']));
+
+                foreach ($optionData['values'] ?? [] as $valueData) {
+                    $option->values()->create(Arr::only($valueData, ['value', 'position']));
+                }
+            }
+
+            $variants = $data['variants'] ?? [];
+
+            if ($variants === []) {
+                $variants = [[
+                    'sku' => $data['sku'] ?? null,
+                    'price_amount' => $data['price_amount'] ?? 0,
+                    'currency' => $store->default_currency,
+                    'is_default' => true,
+                    'position' => 0,
+                ]];
+            }
+
+            foreach ($variants as $variantData) {
+                $this->createVariant($product, $store, $variantData);
+            }
+
+            return $product->refresh();
+        });
+    }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public function update(Product $product, array $data): Product
+    {
+        return DB::transaction(function () use ($product, $data): Product {
+            $storeId = $product->store_id;
+
+            $product->fill(Arr::only($data, [
+                'title',
+                'status',
+                'description_html',
+                'vendor',
+                'product_type',
+                'tags',
+                'published_at',
+            ]));
+
+            if (array_key_exists('handle', $data)) {
+                $product->handle = $data['handle'];
+            } elseif (array_key_exists('title', $data)) {
+                $product->handle = $this->handles->generate($data['title'], 'products', $storeId, $product->getKey());
+            }
+
+            $product->save();
+
+            return $product->refresh();
+        });
+    }
+
+    public function transitionStatus(Product $product, ProductStatus $newStatus): void
+    {
+        $currentStatus = $this->productStatus($product);
+
+        if ($currentStatus === $newStatus) {
+            return;
+        }
+
+        if ($newStatus === ProductStatus::Active) {
+            $this->assertCanActivate($product);
+        }
+
+        if ($newStatus === ProductStatus::Draft && $this->hasOrderLineReferences($product)) {
+            throw InvalidProductTransitionException::because('Products with order history cannot be reverted to draft.');
+        }
+
+        if (! $this->isAllowedTransition($currentStatus, $newStatus)) {
+            throw InvalidProductTransitionException::because("Cannot transition product from {$currentStatus->value} to {$newStatus->value}.");
+        }
+
+        $product->forceFill([
+            'status' => $newStatus,
+            'published_at' => $newStatus === ProductStatus::Active
+                ? ($product->published_at ?? now())
+                : $product->published_at,
+        ])->save();
+
+        ProductStatusChanged::dispatch($product->refresh(), $currentStatus, $newStatus);
+    }
+
+    public function delete(Product $product): void
+    {
+        if ($this->productStatus($product) !== ProductStatus::Draft || $this->hasOrderLineReferences($product)) {
+            throw InvalidProductTransitionException::because('Only draft products with no order history can be deleted.');
+        }
+
+        $product->delete();
+    }
+
+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function createVariant(Product $product, Store $store, array $variantData): ProductVariant
     {
-        //
+        $this->assertSkuIsUnique($store, $variantData['sku'] ?? null);
+
+        $variant = $product->variants()->create([
+            ...Arr::only($variantData, [
+                'sku',
+                'barcode',
+                'price_amount',
+                'compare_at_amount',
+                'currency',
+                'weight_g',
+                'requires_shipping',
+                'is_default',
+                'position',
+                'status',
+            ]),
+            'currency' => $variantData['currency'] ?? $store->default_currency,
+            'status' => $variantData['status'] ?? VariantStatus::Active,
+        ]);
+
+        InventoryItem::query()->create([
+            'store_id' => $store->getKey(),
+            'variant_id' => $variant->getKey(),
+            'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
+            'quantity_reserved' => 0,
+            'policy' => $variantData['inventory_policy'] ?? 'deny',
+        ]);
+
+        return $variant;
+    }
+
+    private function assertCanActivate(Product $product): void
+    {
+        if (trim((string) $product->title) === '') {
+            throw InvalidProductTransitionException::because('A product title is required before activation.');
+        }
+
+        if (! $product->variants()->where('price_amount', '>', 0)->exists()) {
+            throw InvalidProductTransitionException::because('At least one priced variant is required before activation.');
+        }
+    }
+
+    private function isAllowedTransition(ProductStatus $from, ProductStatus $to): bool
+    {
+        return match ($from) {
+            ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true),
+            ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true),
+            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
+        };
+    }
+
+    private function hasOrderLineReferences(Product $product): bool
+    {
+        if (! Schema::hasTable('order_lines')) {
+            return false;
+        }
+
+        $variantIds = $product->variants()->pluck('id');
+
+        if ($variantIds->isEmpty()) {
+            return false;
+        }
+
+        return DB::table('order_lines')
+            ->whereIn('variant_id', $variantIds)
+            ->exists();
+    }
+
+    private function assertSkuIsUnique(Store $store, ?string $sku, ?int $excludeVariantId = null): void
+    {
+        if ($sku === null || trim($sku) === '') {
+            return;
+        }
+
+        $query = ProductVariant::query()
+            ->where('sku', $sku)
+            ->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey()));
+
+        if ($excludeVariantId !== null) {
+            $query->whereKeyNot($excludeVariantId);
+        }
+
+        if ($query->exists()) {
+            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
+        }
+    }
+
+    private function productStatus(Product $product): ProductStatus
+    {
+        return $product->status instanceof ProductStatus
+            ? $product->status
+            : ProductStatus::from($product->status);
     }
 }
*** End Patch


Success. Updated the following files:
M /Users/fabianwesner/Herd/shop/app/Services/ProductService.php

@@ -4,10 +4,235 @@

+use App\Enums\ProductStatus;
+use App\Enums\VariantStatus;
+use App\Events\ProductStatusChanged;
+use App\Exceptions\InvalidProductTransitionException;
+use App\Models\InventoryItem;
+use App\Models\Product;
+use App\Models\ProductVariant;
+use App\Models\Store;
+use App\Support\HandleGenerator;
+use Illuminate\Support\Arr;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use RuntimeException;
+
 class ProductService
 {
+    public function __construct(
+        private readonly HandleGenerator $handles,
+    ) {}
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    public function create(Store $store, array $data): Product
+    {
+        return DB::transaction(function () use ($store, $data): Product {
+            $product = Product::query()->create([
+                ...Arr::only($data, [
+                    'title',
+                    'status',
+                    'description_html',
+                    'vendor',
+                    'product_type',
+                    'tags',
+                    'published_at',
+                ]),
+                'store_id' => $store->getKey(),
+                'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
+            ]);
+
+            foreach ($data['options'] ?? [] as $optionData) {
+                $option = $product->options()->create(Arr::only($optionData, ['name', 'position']));
+
+                foreach ($optionData['values'] ?? [] as $valueData) {
+                    $option->values()->create(Arr::only($valueData, ['value', 'position']));
+                }
+            }
+
+            $variants = $data['variants'] ?? [];
+
+            if ($variants === []) {
+                $variants = [[
+                    'sku' => $data['sku'] ?? null,
+                    'price_amount' => $data['price_amount'] ?? 0,
+                    'currency' => $store->default_currency,
+                    'is_default' => true,
+                    'position' => 0,
+                ]];
+            }
+
+            foreach ($variants as $variantData) {
+                $this->createVariant($product, $store, $variantData);
+            }
+
+            return $product->refresh();
+        });
+    }
+
     /**
-     * Create a new class instance.
+     * @param  array<string, mixed>  $data
      */
-    public function __construct()
+    public function update(Product $product, array $data): Product
+    {
+        return DB::transaction(function () use ($product, $data): Product {
+            $storeId = $product->store_id;
+
+            $product->fill(Arr::only($data, [
+                'title',
+                'status',
+                'description_html',
+                'vendor',
+                'product_type',
+                'tags',
+                'published_at',
+            ]));
+
+            if (array_key_exists('handle', $data)) {
+                $product->handle = $data['handle'];
+            } elseif (array_key_exists('title', $data)) {
+                $product->handle = $this->handles->generate($data['title'], 'products', $storeId, $product->getKey());
+            }
+
+            $product->save();
+
+            return $product->refresh();
+        });
+    }
+
+    public function transitionStatus(Product $product, ProductStatus $newStatus): void
     {
-        //
+        $currentStatus = $this->productStatus($product);
+
+        if ($currentStatus === $newStatus) {
+            return;
+        }
+
+        if ($newStatus === ProductStatus::Active) {
+            $this->assertCanActivate($product);
+        }
+
+        if ($newStatus === ProductStatus::Draft && $this->hasOrderLineReferences($product)) {
+            throw InvalidProductTransitionException::because('Products with order history cannot be reverted to draft.');
+        }
+
+        if (! $this->isAllowedTransition($currentStatus, $newStatus)) {
+            throw InvalidProductTransitionException::because("Cannot transition product from {$currentStatus->value} to {$newStatus->value}.");
+        }
+
+        $product->forceFill([
+            'status' => $newStatus,
+            'published_at' => $newStatus === ProductStatus::Active
+                ? ($product->published_at ?? now())
+                : $product->published_at,
+        ])->save();
+
+        ProductStatusChanged::dispatch($product->refresh(), $currentStatus, $newStatus);
+    }
+
+    public function delete(Product $product): void
+    {
+        if ($this->productStatus($product) !== ProductStatus::Draft || $this->hasOrderLineReferences($product)) {
+            throw InvalidProductTransitionException::because('Only draft products with no order history can be deleted.');
+        }
+
+        $product->delete();
+    }
+
+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function createVariant(Product $product, Store $store, array $variantData): ProductVariant
+    {
+        $this->assertSkuIsUnique($store, $variantData['sku'] ?? null);
+
+        $variant = $product->variants()->create([
+            ...Arr::only($variantData, [
+                'sku',
+                'barcode',
+                'price_amount',
+                'compare_at_amount',
+                'currency',
+                'weight_g',
+                'requires_shipping',
+                'is_default',
+                'position',
+                'status',
+            ]),
+            'currency' => $variantData['currency'] ?? $store->default_currency,
+            'status' => $variantData['status'] ?? VariantStatus::Active,
+        ]);
+
+        InventoryItem::query()->create([
+            'store_id' => $store->getKey(),
+            'variant_id' => $variant->getKey(),
+            'quantity_on_hand' => $variantData['quantity_on_hand'] ?? 0,
+            'quantity_reserved' => 0,
+            'policy' => $variantData['inventory_policy'] ?? 'deny',
+        ]);
+
+        return $variant;
+    }
+
+    private function assertCanActivate(Product $product): void
+    {
+        if (trim((string) $product->title) === '') {
+            throw InvalidProductTransitionException::because('A product title is required before activation.');
+        }
+
+        if (! $product->variants()->where('price_amount', '>', 0)->exists()) {
+            throw InvalidProductTransitionException::because('At least one priced variant is required before activation.');
+        }
+    }
+
+    private function isAllowedTransition(ProductStatus $from, ProductStatus $to): bool
+    {
+        return match ($from) {
+            ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true),
+            ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true),
+            ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true),
+        };
+    }
+
+    private function hasOrderLineReferences(Product $product): bool
+    {
+        if (! Schema::hasTable('order_lines')) {
+            return false;
+        }
+
+        $variantIds = $product->variants()->pluck('id');
+
+        if ($variantIds->isEmpty()) {
+            return false;
+        }
+
+        return DB::table('order_lines')
+            ->whereIn('variant_id', $variantIds)
+            ->exists();
+    }
+
+    private function assertSkuIsUnique(Store $store, ?string $sku, ?int $excludeVariantId = null): void
+    {
+        if ($sku === null || trim($sku) === '') {
+            return;
+        }
+
+        $query = ProductVariant::query()
+            ->where('sku', $sku)
+            ->whereHas('product', fn ($query) => $query->where('store_id', $store->getKey()));
+
+        if ($excludeVariantId !== null) {
+            $query->whereKeyNot($excludeVariantId);
+        }
+
+        if ($query->exists()) {
+            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
+        }
+    }
+
+    private function productStatus(Product $product): ProductStatus
+    {
+        return $product->status instanceof ProductStatus
+            ? $product->status
+            : ProductStatus::from($product->status);
     }


Output: {"output":"Success. Updated the following files:\nM /Users/fabianwesner/Herd/shop/app/Services/ProductService.php\n","metadata":{"exit_code":0,"duration_seconds":0.0}}