Output:
Chunk ID: e0a59f
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1845
Output:
# 07 - Seeders and Test Data Specification

> Complete seeder and factory specification for populating the platform with realistic demo data. Laravel 12, SQLite. All monetary amounts in cents (INTEGER). All seed data supports Playwright E2E tests.

---

## 1. DatabaseSeeder Orchestration

The main `DatabaseSeeder` calls child seeders in a strict order that respects foreign key dependencies. Each seeder targets a specific entity type. The ordering ensures parent records exist before children are created.

**Execution order:**

| Step | Seeder Class | Depends On | Purpose |
|------|-------------|------------|---------|
| 1 | OrganizationSeeder | (none) | Creates billing organizations |
| 2 | StoreSeeder | Organizations | Creates stores under organizations |
| 3 | StoreDomainSeeder | Stores | Attaches hostnames to stores |
| 4 | UserSeeder | (none) | Creates admin/staff user accounts |
| 5 | StoreUserSeeder | Stores, Users | Links users to stores with roles (pivot) |
| 6 | StoreSettingsSeeder | Stores | Inserts per-store JSON settings |
| 7 | TaxSettingsSeeder | Stores | Configures tax mode and rates |
| 8 | ShippingSeeder | Stores | Creates shipping zones and rates |
| 9 | CollectionSeeder | Stores | Creates product collections |
| 10 | ProductSeeder | Stores, Collections | Creates products, options, variants, inventory, collection assignments |
| 11 | DiscountSeeder | Stores | Creates discount codes |
| 12 | CustomerSeeder | Stores | Creates customer accounts with addresses |
| 13 | OrderSeeder | Stores, Customers, Products, Variants, Discounts | Creates orders with lines, payments, fulfillments, refunds |
| 14 | ThemeSeeder | Stores | Creates themes with settings |
| 15 | PageSeeder | Stores | Creates content pages |
| 16 | NavigationSeeder | Stores, Collections, Pages | Creates menus linking to collections and pages |
| 17 | AnalyticsSeeder | Stores, Customers | Creates daily aggregates and event stream |
| 18 | SearchSettingsSeeder | Stores | Configures search synonyms and stop words |

**Dependency diagram (Mermaid):**

```mermaid
graph TD
    A[OrganizationSeeder] --> B[StoreSeeder]
    B --> C[StoreDomainSeeder]
    B --> F[StoreSettingsSeeder]
    B --> G[TaxSettingsSeeder]
    B --> H[ShippingSeeder]
    B --> I[CollectionSeeder]
    D[UserSeeder] --> E[StoreUserSeeder]
    B --> E
    B --> J[ProductSeeder]
    I --> J
    B --> K[DiscountSeeder]
    B --> L[CustomerSeeder]
    J --> M[OrderSeeder]
    L --> M
    K --> M
    B --> N[ThemeSeeder]
    B --> O[PageSeeder]
    I --> P[NavigationSeeder]
    O --> P
    B --> Q[AnalyticsSeeder]
    L --> Q
    B --> R[SearchSettingsSeeder]
```

**Running seeders:**
- All seeders: `php artisan db:seed`
- Single seeder: `php artisan db:seed --class=ProductSeeder`
- Fresh database with seed data: `php artisan migrate:fresh --seed`

---

## 2. Factory Definitions

Each factory below lists the model, default field generation rules, named states (overrides), and relationship hooks. The coding agent should create corresponding Laravel factory classes.

---

### 2.1 OrganizationFactory

**Model:** Organization

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| name | Faker company name |
| billing_email | Faker company email |

**States:** None.

---

### 2.2 StoreFactory

**Model:** Store

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| organization_id | Creates a new Organization via its factory |
| name | Faker company name + " Store" suffix |
| handle | Faker unique slug (2 words) |
| status | "active" |
| default_currency | "EUR" |
| default_locale | "en" |
| timezone | "Europe/Berlin" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `suspended` | status = "suspended" |

---

### 2.3 StoreDomainFactory

**Model:** StoreDomain

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| hostname | Faker unique domain name |
| type | "storefront" |
| is_primary | 1 |
| tls_mode | "managed" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `admin` | type = "admin" |
| `api` | type = "api" |
| `secondary` | is_primary = 0 |

---

### 2.4 UserFactory

**Model:** User

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| email | Faker unique safe email |
| password_hash | bcrypt of "password" |
| name | Faker full name |
| status | "active" |
| last_login_at | Random date within the last 30 days |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `disabled` | status = "disabled" |

---

### 2.5 ProductFactory

**Model:** Product

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| title | Faker 3 random words joined |
| handle | URL-slug of the title |
| status | "active" |
| description_html | Faker 2 paragraphs wrapped in `<p>` tags |
| vendor | Faker company name |
| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |
| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |
| published_at | Current timestamp |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `draft` | status = "draft", published_at = null |
| `archived` | status = "archived" |

**Relationship hooks:**

| Hook Name | Behavior |
|-----------|----------|
| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |
| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |

---

### 2.6 ProductVariantFactory

**Model:** ProductVariant

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| product_id | Creates a new Product via its factory |
| sku | Faker unique, pattern: "SKU-####-???" (digits and letters) |
| barcode | Faker EAN-13 |
| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |
| compare_at_amount | null |
| currency | "EUR" |
| weight_g | Random integer 100-5000 |
| requires_shipping | 1 |
| is_default | 0 |
| position | 0 |
| status | "active" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |
| `digital` | requires_shipping = 0, weight_g = 0 |
| `default` | is_default = 1 |
| `archived` | status = "archived" |

---

### 2.7 InventoryItemFactory

**Model:** InventoryItem

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| variant_id | Creates a new ProductVariant via its factory |
| quantity_on_hand | Random integer 0-100 |
| quantity_reserved | 0 |
| policy | "deny" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `outOfStock` | quantity_on_hand = 0 |
| `continuePolicy` | policy = "continue" |
| `lowStock` | quantity_on_hand = random 1-3 |

---

### 2.8 CollectionFactory

**Model:** Collection

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| title | Faker 2 random words joined |
| handle | URL-slug of the title |
| description_html | Faker sentence wrapped in `<p>` tags |


Output:
Chunk ID: 8c8bfa
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 2836
Output:
# 01 - Database Schema Specification

> E-commerce platform - SQLite database schema for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Primary keys | Auto-incrementing integer primary key on every table (column: `id`) |
| Tenant isolation | `store_id` foreign key on every tenant-scoped table |
| Monetary amounts | Stored as **integer in minor units** (cents). Column names end in `_amount`. |
| Booleans | Integer, not null, default 0 (0 = false, 1 = true) |
| JSON data | Text column - Laravel casts to/from JSON at the application layer |
| Enumerations | Check constraints on text columns |
| Timestamps | Text (ISO-8601) via Laravel's `created_at` / `updated_at` |
| Soft deletes | Only where explicitly noted (`deleted_at` text, nullable) |
| Encrypted columns | Suffixed `_encrypted` - encrypted at application layer, stored as text |

---

## Epic 1 - Foundation and Multi-Store Tenancy

### organizations

Billing entity that owns one or more stores.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | Organization display name |
| billing_email | TEXT | No | - | - | Primary billing contact |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_organizations_billing_email` on (`billing_email`) - Supports lookup by billing email

---

### stores

Individual storefront belonging to an organization.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| organization_id | INTEGER | No | - | FK -> organizations(id) ON DELETE CASCADE | Parent organization |
| name | TEXT | No | - | - | Store display name |
| handle | TEXT | No | - | UNIQUE per table | URL-friendly slug |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
| default_currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| default_locale | TEXT | No | 'en' | - | IETF locale tag |
| timezone | TEXT | No | 'UTC' | - | IANA timezone |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_stores_handle` UNIQUE on (`handle`) - Ensures globally unique store handles for URL routing
- `idx_stores_organization_id` on (`organization_id`) - FK lookup and listing stores by org
- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status

---

### store_domains

Custom domains attached 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 |
| hostname | TEXT | No | - | UNIQUE per table | e.g. "shop.example.com" |
| type | TEXT | No | 'storefront' | CHECK IN ('storefront', 'admin', 'api') | Domain purpose |
| is_primary | INTEGER | No | 0 | - | Boolean: primary domain flag |
| tls_mode | TEXT | No | 'managed' | CHECK IN ('managed', 'bring_your_own') | TLS certificate mode |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_store_domains_hostname` UNIQUE on (`hostname`) - Globally unique hostname for routing
- `idx_store_domains_store_id` on (`store_id`) - FK lookup, listing domains per store
- `idx_store_domains_store_primary` on (`store_id`, `is_primary`) - Quick lookup of a store's primary domain

---

### users

Admin/staff users who manage stores (not storefront customers).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| email | TEXT | No | - | UNIQUE per table | Login email |
| password_hash | TEXT | No | - | - | bcrypt/argon2 hash; maps to Laravel's `password` field |
| name | TEXT | No | - | - | Display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| email_verified_at | TEXT | Yes | NULL | - | ISO-8601 timestamp; required for `verified` middleware |
| last_login_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| two_factor_secret | TEXT | Yes | NULL | - | TOTP secret |
| two_factor_recovery_codes | TEXT | Yes | NULL | - | JSON array of recovery codes |
| two_factor_confirmed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| remember_token | TEXT | Yes | NULL | - | Laravel remember-me token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_users_email` UNIQUE on (`email`) - Enforces unique email, supports login lookup
- `idx_users_status` on (`status`) - Filtering users by active/disabled

**Notes:** The `two_factor_*` columns align with the existing migration `2025_08_14_170933_add_two_factor_columns_to_users_table.php`. The `password_hash` column maps to Laravel's default `password` field internally but stores a bcrypt/argon2 hash.

---

### store_users

Pivot table linking users to stores with a role. Uses composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| user_id | INTEGER | No | - | PK (composite), FK -> users(id) ON DELETE CASCADE | |
| role | TEXT | No | 'staff' | CHECK IN ('owner', 'admin', 'staff', 'support') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Primary Key:** (`store_id`, `user_id`)

**Indexes:**

- `idx_store_users_user_id` on (`user_id`) - Reverse lookup: find all stores for a user
- `idx_store_users_role` on (`store_id`, `role`) - Listing users by role within a store

---

### store_settings

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 |
|---|---|---|---|---|---|
| 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 |


sed -n '260,390p' specs/01-DATABASE-SCHEMA.md


sed -n '260,390p' specs/01-DATABASE-SCHEMA.md

| 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:**

- `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

---


Output:
Chunk ID: 0c5cc2
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 1447
Output:
| 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:**

- `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

---


sed -n '105,185p' specs/05-BUSINESS-LOGIC.md


sed -n '105,185p' specs/05-BUSINESS-LOGIC.md

- Register gate definitions in a service provider.
- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.

---

## 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.


Output:
Chunk ID: a32262
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 909
Output:
- Register gate definitions in a service provider.
- Each policy method receives the User, looks up the user's role for the current store via the `store_users` pivot table, and checks the matrix above.
- Add a helper method on the User model - `roleForStore(store)` - that returns the user's role for a given store or null if the user has no access.

---

## 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.


rg -n "function check|check\(" vendor/laravel/framework/src/Illuminate/Database/Schema -g'*.php'


sed -n '1,220p' database/factories/ProductOptionFactory.php


sed -n '1,220p' database/factories/ProductOptionValueFactory.php


sed -n '1,260p' app/Jobs/ProcessMediaUpload.php


sed -n '1,260p' app/Jobs/ProcessMediaUpload.php

<?php

namespace App\Jobs;

use App\Enums\MediaStatus;
use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $productMediaId,
    ) {}

    public function handle(): void
    {
        $media = ProductMedia::query()->findOrFail($this->productMediaId);

        try {
            if (! Storage::disk('public')->exists($media->storage_key)) {
                throw new \RuntimeException('Media file is missing from public storage.');
            }

            $media->forceFill([
                'status' => MediaStatus::Ready,
            ])->save();
        } catch (Throwable $throwable) {
            $media->forceFill([
                'status' => MediaStatus::Failed,
            ])->save();

            throw $throwable;
        }
    }
}


sed -n '1,220p' database/factories/ProductOptionFactory.php

<?php

namespace Database\Factories;

use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductOption>
 */
class ProductOptionFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'name' => fake()->randomElement(['Size', 'Color', 'Material']),
            'position' => 0,
        ];
    }
}


sed -n '1,220p' database/factories/ProductOptionValueFactory.php

<?php

namespace Database\Factories;

use App\Models\ProductOption;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductOptionValue>
 */
class ProductOptionValueFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_option_id' => ProductOption::factory(),
            'value' => fake()->randomElement(['S', 'M', 'L', 'Black', 'White', 'Navy']),
            'position' => 0,
        ];
    }
}


rg -n "function check|check\(" vendor/laravel/framework/src/Illuminate/Database/Schema -g'*.php'

rg -n "function check|check\\(" vendor/laravel/framework/src/Illuminate/Database/Schema '-g*.php'

Question: function check|check\(


Output:
Chunk ID: 8c0df4
Wall time: 0.0000 seconds
Process exited with code 1
Original token count: 0
Output:


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

namespace Database\Factories;

use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductOption>
 */
class ProductOptionFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'name' => fake()->randomElement(['Size', 'Color', 'Material']),
            'position' => 0,
        ];
    }
}


Output:
Chunk ID: 66a59e
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 158
Output:
<?php

namespace Database\Factories;

use App\Models\ProductOption;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductOptionValue>
 */
class ProductOptionValueFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_option_id' => ProductOption::factory(),
            'value' => fake()->randomElement(['S', 'M', 'L', 'Black', 'White', 'Navy']),
            'position' => 0,
        ];
    }
}


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

namespace App\Jobs;

use App\Enums\MediaStatus;
use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Storage;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public int $productMediaId,
    ) {}

    public function handle(): void
    {
        $media = ProductMedia::query()->findOrFail($this->productMediaId);

        try {
            if (! Storage::disk('public')->exists($media->storage_key)) {
                throw new \RuntimeException('Media file is missing from public storage.');
            }

            $media->forceFill([
                'status' => MediaStatus::Ready,
            ])->save();
        } catch (Throwable $throwable) {
            $media->forceFill([
                'status' => MediaStatus::Failed,
            ])->save();

            throw $throwable;
        }
    }
}


php -m | rg -n "^(gd|imagick|exif|fileinfo)$"


Output:
Chunk ID: 273b66
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 10
Output:
11:exif
13:fileinfo
16:gd
22:imagick


sed -n '900,1445p' specs/07-SEEDERS-AND-TEST-DATA.md


sed -n '900,1445p' specs/07-SEEDERS-AND-TEST-DATA.md

**Zone: "Germany"**
- countries_json: ["DE"]

| Rate Name | Type | config_json | is_active |
|-----------|------|-------------|-----------|
| Standard | flat | amount: 0 (free) | 1 |

---

### 3.9 CollectionSeeder

**Acme Fashion - 4 collections:**

| # | Title | Handle | Type | Status | description_html |
|---|-------|--------|------|--------|------------------|
| 1 | New Arrivals | new-arrivals | manual | active | "Discover the latest additions to our store." in `<p>` tag |
| 2 | T-Shirts | t-shirts | manual | active | "Premium cotton tees for every occasion." in `<p>` tag |
| 3 | Pants & Jeans | pants-jeans | manual | active | "Find the perfect fit from our denim and trouser range." in `<p>` tag |
| 4 | Sale | sale | manual | active | "Great deals on selected items." in `<p>` tag |

**Acme Electronics - 2 collections:**

| # | Title | Handle | Type | Status |
|---|-------|--------|------|--------|
| 1 | Featured | featured | manual | active |
| 2 | Accessories | accessories | manual | active |

---

### 3.10 ProductSeeder

#### Acme Fashion - 20 Products

For each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.

---

**Product 1: "Classic Cotton T-Shirt"**

| Field | Value |
|-------|-------|
| handle | classic-cotton-t-shirt |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["new", "popular"] |
| description_html | "A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear." in `<p>` tag |
| published_at | now |

Collections: New Arrivals, T-Shirts

Options and values:

| Option Name | Values (in position order) |
|-------------|---------------------------|
| Size | S, M, L, XL |
| Color | White, Black, Navy |

Variants (12 total - all size x color combinations):
- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)
- First variant (S/White) is marked is_default = 1
- Position increments 0 through 11

| Per-variant field | Value |
|-------------------|-------|
| price_amount | 2499 (24.99 EUR) |
| compare_at_amount | null |
| currency | EUR |
| weight_g | 200 |
| requires_shipping | 1 |

Inventory per variant: quantity_on_hand = 15, policy = "deny"

---

**Product 2: "Premium Slim Fit Jeans"**

| Field | Value |
|-------|-------|
| handle | premium-slim-fit-jeans |
| status | active |
| vendor | Acme Denim |
| product_type | Pants |
| tags | ["new", "sale"] |
| description_html | "Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette." in `<p>` tag |
| published_at | now |

Collections: New Arrivals, Pants & Jeans, Sale

Options and values:

| Option Name | Values |
|-------------|--------|
| Size | 28, 30, 32, 34, 36 |
| Color | Blue, Black |

Variants (10 total): First variant (28/Blue) is default.

| Per-variant field | Value |
|-------------------|-------|
| price_amount | 7999 (79.99 EUR) |
| compare_at_amount | 9999 (99.99 EUR - on sale) |
| weight_g | 800 |
| requires_shipping | 1 |

Inventory per variant: quantity_on_hand = 8, policy = "deny"

---

**Product 3: "Organic Hoodie"**

| Field | Value |
|-------|-------|
| handle | organic-hoodie |
| status | active |
| vendor | Acme Basics |
| product_type | Hoodies |
| tags | ["new", "trending"] |
| description_html | "Made from 100% organic cotton. Warm, soft, and sustainably produced." |
| published_at | now |

Collections: New Arrivals

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)

---

**Product 4: "Leather Belt"**

| Field | Value |
|-------|-------|
| handle | leather-belt |
| status | active |
| vendor | Acme Accessories |
| product_type | Accessories |
| tags | ["popular"] |
| description_html | "Genuine leather belt with brushed metal buckle. A wardrobe essential." |
| published_at | now |

Collections: (none)

Options: Size (S/M, L/XL), Color (Brown, Black)

Variants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)

---

**Product 5: "Running Sneakers"**

| Field | Value |
|-------|-------|
| handle | running-sneakers |
| status | active |
| vendor | Acme Sport |
| product_type | Shoes |
| tags | ["trending"] |
| description_html | "Lightweight running sneakers with responsive cushioning and breathable mesh upper." |
| published_at | now |

Collections: New Arrivals

Options: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)

Variants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK

---

**Product 6: "Graphic Print Tee"**

| Field | Value |
|-------|-------|
| handle | graphic-print-tee |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["new"] |
| description_html | "Bold graphic print on soft cotton. Express yourself with this statement piece." |
| published_at | no…7146 chars truncated… (digital)** | 9999 (deny) |
| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |
| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |

**Test purpose:** Tests digital product flow (no shipping step in checkout).

---

**Product 20: "Cashmere Overcoat" -- EXPENSIVE**

| Field | Value |
|-------|-------|
| handle | cashmere-overcoat |
| status | active |
| vendor | Acme Premium |
| product_type | Jackets |
| tags | ["limited", "new"] |
| description_html | "Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining." |
| published_at | now |

Collections: New Arrivals

Options: Size (S, M, L), Color (Camel, Charcoal)

Variants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)

**Test purpose:** Tests high-value price display (499.99 EUR).

---

#### Product Summary Table (Acme Fashion)

| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |
|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|
| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |
| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |
| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |
| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |
| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |
| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |
| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |
| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |
| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |
| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |
| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |
| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |
| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |
| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |
| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |
| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |
| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |
| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |
| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |
| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |

**Total variants across Acme Fashion:** 107

---

#### Acme Electronics - 5 Products (minimal seed for tenant isolation)

These products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.

| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |
|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|
| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |
| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |
| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |
| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |
| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |

All active, published now, tags vary. Inventory policy = "deny" for all.

**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.

---

### 3.11 DiscountSeeder

**Acme Fashion - 5 discount codes:**

| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |
|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|
| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |
| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |
| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |
| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |
| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |


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

**Zone: "Germany"**
- countries_json: ["DE"]

| Rate Name | Type | config_json | is_active |
|-----------|------|-------------|-----------|
| Standard | flat | amount: 0 (free) | 1 |

---

### 3.9 CollectionSeeder

**Acme Fashion - 4 collections:**

| # | Title | Handle | Type | Status | description_html |
|---|-------|--------|------|--------|------------------|
| 1 | New Arrivals | new-arrivals | manual | active | "Discover the latest additions to our store." in `<p>` tag |
| 2 | T-Shirts | t-shirts | manual | active | "Premium cotton tees for every occasion." in `<p>` tag |
| 3 | Pants & Jeans | pants-jeans | manual | active | "Find the perfect fit from our denim and trouser range." in `<p>` tag |
| 4 | Sale | sale | manual | active | "Great deals on selected items." in `<p>` tag |

**Acme Electronics - 2 collections:**

| # | Title | Handle | Type | Status |
|---|-------|--------|------|--------|
| 1 | Featured | featured | manual | active |
| 2 | Accessories | accessories | manual | active |

---

### 3.10 ProductSeeder

#### Acme Fashion - 20 Products

For each product the seeder creates the full entity graph: product record, product_options, product_option_values, product_variants (with variant_option_values pivot entries), and inventory_items.

---

**Product 1: "Classic Cotton T-Shirt"**

| Field | Value |
|-------|-------|
| handle | classic-cotton-t-shirt |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["new", "popular"] |
| description_html | "A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear." in `<p>` tag |
| published_at | now |

Collections: New Arrivals, T-Shirts

Options and values:

| Option Name | Values (in position order) |
|-------------|---------------------------|
| Size | S, M, L, XL |
| Color | White, Black, Navy |

Variants (12 total - all size x color combinations):
- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)
- First variant (S/White) is marked is_default = 1
- Position increments 0 through 11

| Per-variant field | Value |
|-------------------|-------|
| price_amount | 2499 (24.99 EUR) |
| compare_at_amount | null |
| currency | EUR |
| weight_g | 200 |
| requires_shipping | 1 |

Inventory per variant: quantity_on_hand = 15, policy = "deny"

---

**Product 2: "Premium Slim Fit Jeans"**

| Field | Value |
|-------|-------|
| handle | premium-slim-fit-jeans |
| status | active |
| vendor | Acme Denim |
| product_type | Pants |
| tags | ["new", "sale"] |
| description_html | "Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette." in `<p>` tag |
| published_at | now |

Collections: New Arrivals, Pants & Jeans, Sale

Options and values:

| Option Name | Values |
|-------------|--------|
| Size | 28, 30, 32, 34, 36 |
| Color | Blue, Black |

Variants (10 total): First variant (28/Blue) is default.

| Per-variant field | Value |
|-------------------|-------|
| price_amount | 7999 (79.99 EUR) |
| compare_at_amount | 9999 (99.99 EUR - on sale) |
| weight_g | 800 |
| requires_shipping | 1 |

Inventory per variant: quantity_on_hand = 8, policy = "deny"

---

**Product 3: "Organic Hoodie"**

| Field | Value |
|-------|-------|
| handle | organic-hoodie |
| status | active |
| vendor | Acme Basics |
| product_type | Hoodies |
| tags | ["new", "trending"] |
| description_html | "Made from 100% organic cotton. Warm, soft, and sustainably produced." |
| published_at | now |

Collections: New Arrivals

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 5999, weight_g = 500, inventory = 20 each (deny)

---

**Product 4: "Leather Belt"**

| Field | Value |
|-------|-------|
| handle | leather-belt |
| status | active |
| vendor | Acme Accessories |
| product_type | Accessories |
| tags | ["popular"] |
| description_html | "Genuine leather belt with brushed metal buckle. A wardrobe essential." |
| published_at | now |

Collections: (none)

Options: Size (S/M, L/XL), Color (Brown, Black)

Variants (4 total): price_amount = 3499, weight_g = 150, inventory = 25 each (deny)

---

**Product 5: "Running Sneakers"**

| Field | Value |
|-------|-------|
| handle | running-sneakers |
| status | active |
| vendor | Acme Sport |
| product_type | Shoes |
| tags | ["trending"] |
| description_html | "Lightweight running sneakers with responsive cushioning and breathable mesh upper." |
| published_at | now |

Collections: New Arrivals

Options: Size (EU 38, EU 39, EU 40, EU 41, EU 42, EU 43, EU 44), Color (White, Black)

Variants (14 total): price_amount = 11999, weight_g = 600, inventory = 5 each (deny) - LOW STOCK

---

**Product 6: "Graphic Print Tee"**

| Field | Value |
|-------|-------|
| handle | graphic-print-tee |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["new"] |
| description_html | "Bold graphic print on soft cotton. Express yourself with this statement piece." |
| published_at | now |

Collections: T-Shirts

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 2999, weight_g = 210, inventory = 18 each (deny)

---

**Product 7: "V-Neck Linen Tee"**

| Field | Value |
|-------|-------|
| handle | v-neck-linen-tee |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["popular"] |
| description_html | "Lightweight linen blend v-neck. Perfect for warm summer days." |
| published_at | now |

Collections: T-Shirts

Options: Size (S, M, L), Color (Beige, Olive, Sky Blue)

Variants (9 total): price_amount = 3499, weight_g = 180, inventory = 12 each (deny)

---

**Product 8: "Striped Polo Shirt"**

| Field | Value |
|-------|-------|
| handle | striped-polo-shirt |
| status | active |
| vendor | Acme Basics |
| product_type | T-Shirts |
| tags | ["sale"] |
| description_html | "Classic striped polo with a modern relaxed fit. Knitted collar and two-button placket." |
| published_at | now |

Collections: T-Shirts, Sale

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 2799, compare_at_amount = 3999 (on sale), weight_g = 250, inventory = 10 each (deny)

---

**Product 9: "Cargo Pants"**

| Field | Value |
|-------|-------|
| handle | cargo-pants |
| status | active |
| vendor | Acme Workwear |
| product_type | Pants |
| tags | ["popular"] |
| description_html | "Utility cargo pants with multiple pockets. Durable cotton twill construction." |
| published_at | now |

Collections: Pants & Jeans

Options: Size (30, 32, 34, 36), Color (Khaki, Olive, Black)

Variants (12 total): price_amount = 5499, weight_g = 700, inventory = 14 each (deny)

---

**Product 10: "Chino Shorts"**

| Field | Value |
|-------|-------|
| handle | chino-shorts |
| status | active |
| vendor | Acme Basics |
| product_type | Pants |
| tags | ["new", "trending"] |
| description_html | "Tailored chino shorts. Comfortable stretch fabric with a clean silhouette." |
| published_at | now |

Collections: Pants & Jeans, New Arrivals

Options: Size (30, 32, 34, 36), Color (Navy, Sand)

Variants (8 total): price_amount = 3999, weight_g = 350, inventory = 16 each (deny)

---

**Product 11: "Wide Leg Trousers"**

| Field | Value |
|-------|-------|
| handle | wide-leg-trousers |
| status | active |
| vendor | Acme Denim |
| product_type | Pants |
| tags | ["sale"] |
| description_html | "Relaxed wide leg trousers with a high waist. Flowing drape in premium woven fabric." |
| published_at | now |

Collections: Pants & Jeans, Sale

Options: Size (S, M, L)

Variants (3 total): price_amount = 4999, compare_at_amount = 6999 (on sale), weight_g = 550, inventory = 7 each (deny)

---

**Product 12: "Wool Scarf"**

| Field | Value |
|-------|-------|
| handle | wool-scarf |
| status | active |
| vendor | Acme Accessories |
| product_type | Accessories |
| tags | ["popular"] |
| description_html | "Warm merino wool scarf. Soft hand feel, naturally breathable and temperature regulating." |
| published_at | now |

Collections: (none)

Options: Color (Grey, Burgundy, Navy)

Variants (3 total): price_amount = 2999, weight_g = 120, inventory = 30 each (deny)

---

**Product 13: "Canvas Tote Bag"**

| Field | Value |
|-------|-------|
| handle | canvas-tote-bag |
| status | active |
| vendor | Acme Accessories |
| product_type | Accessories |
| tags | ["trending"] |
| description_html | "Heavy-duty canvas tote bag with reinforced handles. Spacious enough for daily essentials." |
| published_at | now |

Collections: (none)

Options: Color (Natural, Black)

Variants (2 total): price_amount = 1999, weight_g = 300, inventory = 40 each (deny)

---

**Product 14: "Bucket Hat"**

| Field | Value |
|-------|-------|
| handle | bucket-hat |
| status | active |
| vendor | Acme Accessories |
| product_type | Accessories |
| tags | ["new", "trending"] |
| description_html | "Lightweight bucket hat for sun protection. Packable design, washed cotton twill." |
| published_at | now |

Collections: New Arrivals

Options: Size (S/M, L/XL), Color (Beige, Black, Olive)

Variants (6 total): price_amount = 2499, weight_g = 80, inventory = 22 each (deny)

---

**Product 15: "Unreleased Winter Jacket" -- DRAFT**

| Field | Value |
|-------|-------|
| handle | unreleased-winter-jacket |
| status | **draft** |
| vendor | Acme Outerwear |
| product_type | Jackets |
| tags | ["limited"] |
| description_html | "Upcoming winter collection piece. Insulated puffer jacket with water-resistant shell." |
| published_at | **null** |

Collections: (none - draft not assigned)

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 14999, weight_g = 900, inventory = **0 each** (deny)

**Test purpose:** Verifies draft products do not appear on the storefront.

---

**Product 16: "Discontinued Raincoat" -- ARCHIVED**

| Field | Value |
|-------|-------|
| handle | discontinued-raincoat |
| status | **archived** |
| vendor | Acme Outerwear |
| product_type | Jackets |
| tags | [] |
| description_html | "Lightweight waterproof raincoat. This product has been discontinued." |
| published_at | 6 months ago |

Collections: (none)

Options: Size (M, L)

Variants (2 total): price_amount = 8999, weight_g = 400, inventory = 3 each (deny)

**Test purpose:** Verifies archived products do not appear on the storefront.

---

**Product 17: "Limited Edition Sneakers" -- SOLD OUT (deny policy)**

| Field | Value |
|-------|-------|
| handle | limited-edition-sneakers |
| status | active |
| vendor | Acme Sport |
| product_type | Shoes |
| tags | ["limited"] |
| description_html | "Limited edition collaboration sneakers. Once they are gone, they are gone." |
| published_at | now |

Collections: (none)

Options: Size (EU 40, EU 42, EU 44)

Variants (3 total): price_amount = 15999, weight_g = 650, inventory = **0 each, policy = "deny"**

**Test purpose:** Tests "sold out" display and add-to-cart prevention.

---

**Product 18: "Backorder Denim Jacket" -- OUT OF STOCK (continue policy)**

| Field | Value |
|-------|-------|
| handle | backorder-denim-jacket |
| status | active |
| vendor | Acme Denim |
| product_type | Jackets |
| tags | ["popular"] |
| description_html | "Classic denim jacket. Currently on backorder - ships within 2-3 weeks." |
| published_at | now |

Collections: (none)

Options: Size (S, M, L, XL)

Variants (4 total): price_amount = 9999, weight_g = 750, inventory = **0 each, policy = "continue"**

**Test purpose:** Tests that items with "continue" inventory policy can be added to cart despite zero stock.

---

**Product 19: "Gift Card" -- DIGITAL (no shipping)**

| Field | Value |
|-------|-------|
| handle | gift-card |
| status | active |
| vendor | Acme Fashion |
| product_type | Gift Cards |
| tags | ["popular"] |
| description_html | "Digital gift card delivered via email. The perfect gift when you are not sure what to choose." |
| published_at | now |

Collections: (none)

Options: Amount (25 EUR, 50 EUR, 100 EUR)

Variants (3 total - each a different denomination):

| SKU | Price (cents) | Weight | requires_shipping | Inventory |
|-----|---------------|--------|-------------------|-----------|
| ACME-GIFT-25 | 2500 | 0g | **0 (digital)** | 9999 (deny) |
| ACME-GIFT-50 | 5000 | 0g | **0 (digital)** | 9999 (deny) |
| ACME-GIFT-100 | 10000 | 0g | **0 (digital)** | 9999 (deny) |

**Test purpose:** Tests digital product flow (no shipping step in checkout).

---

**Product 20: "Cashmere Overcoat" -- EXPENSIVE**

| Field | Value |
|-------|-------|
| handle | cashmere-overcoat |
| status | active |
| vendor | Acme Premium |
| product_type | Jackets |
| tags | ["limited", "new"] |
| description_html | "Luxurious cashmere-blend overcoat. Impeccable tailoring with silk lining." |
| published_at | now |

Collections: New Arrivals

Options: Size (S, M, L), Color (Camel, Charcoal)

Variants (6 total): price_amount = 49999 (499.99 EUR), weight_g = 1200, inventory = 3 each (deny)

**Test purpose:** Tests high-value price display (499.99 EUR).

---

#### Product Summary Table (Acme Fashion)

| # | Title | Handle | Status | Price (cents) | Compare At | Variants | Inventory | Special |
|---|-------|--------|--------|---------------|-----------|----------|-----------|---------|
| 1 | Classic Cotton T-Shirt | classic-cotton-t-shirt | active | 2499 | - | 12 | 15 ea | - |
| 2 | Premium Slim Fit Jeans | premium-slim-fit-jeans | active | 7999 | 9999 | 10 | 8 ea | On sale |
| 3 | Organic Hoodie | organic-hoodie | active | 5999 | - | 4 | 20 ea | - |
| 4 | Leather Belt | leather-belt | active | 3499 | - | 4 | 25 ea | - |
| 5 | Running Sneakers | running-sneakers | active | 11999 | - | 14 | 5 ea | Low stock |
| 6 | Graphic Print Tee | graphic-print-tee | active | 2999 | - | 4 | 18 ea | - |
| 7 | V-Neck Linen Tee | v-neck-linen-tee | active | 3499 | - | 9 | 12 ea | - |
| 8 | Striped Polo Shirt | striped-polo-shirt | active | 2799 | 3999 | 4 | 10 ea | On sale |
| 9 | Cargo Pants | cargo-pants | active | 5499 | - | 12 | 14 ea | - |
| 10 | Chino Shorts | chino-shorts | active | 3999 | - | 8 | 16 ea | - |
| 11 | Wide Leg Trousers | wide-leg-trousers | active | 4999 | 6999 | 3 | 7 ea | On sale |
| 12 | Wool Scarf | wool-scarf | active | 2999 | - | 3 | 30 ea | - |
| 13 | Canvas Tote Bag | canvas-tote-bag | active | 1999 | - | 2 | 40 ea | - |
| 14 | Bucket Hat | bucket-hat | active | 2499 | - | 6 | 22 ea | - |
| 15 | Unreleased Winter Jacket | unreleased-winter-jacket | draft | 14999 | - | 4 | 0 ea | Draft |
| 16 | Discontinued Raincoat | discontinued-raincoat | archived | 8999 | - | 2 | 3 ea | Archived |
| 17 | Limited Edition Sneakers | limited-edition-sneakers | active | 15999 | - | 3 | 0 ea | Sold out (deny) |
| 18 | Backorder Denim Jacket | backorder-denim-jacket | active | 9999 | - | 4 | 0 ea | Backorder (continue) |
| 19 | Gift Card | gift-card | active | 2500/5000/10000 | - | 3 | 9999 ea | Digital |
| 20 | Cashmere Overcoat | cashmere-overcoat | active | 49999 | - | 6 | 3 ea | Expensive |

**Total variants across Acme Fashion:** 107

---

#### Acme Electronics - 5 Products (minimal seed for tenant isolation)

These products exist for tenant isolation testing. Store 2 data must never appear in Store 1 queries.

| # | Title | Handle | Vendor | Type | Options | Variants | Price (cents) | Weight | Inventory |
|---|-------|--------|--------|------|---------|----------|---------------|--------|-----------|
| E1 | Pro Laptop 15 | pro-laptop-15 | TechCorp | Laptops | Storage: 256GB, 512GB, 1TB | 3 | 99999, 119999, 149999 | 1800g | 10 ea |
| E2 | Wireless Headphones | wireless-headphones | AudioMax | Audio | Color: Black, Silver | 2 | 14999 | 250g | 25 ea |
| E3 | USB-C Cable 2m | usb-c-cable-2m | CablePro | Cables | (none - single default variant) | 1 | 1299 | 50g | 200 |
| E4 | Mechanical Keyboard | mechanical-keyboard | KeyTech | Peripherals | Switch Type: Red, Blue, Brown | 3 | 12999 | 1100g | 15 ea |
| E5 | Monitor Stand | monitor-stand | DeskGear | Accessories | (none - single default variant) | 1 | 4999 | 2500g | 30 |

All active, published now, tags vary. Inventory policy = "deny" for all.

**Note on product media:** No `ProductMedia` records are seeded. The storefront must gracefully handle products without images by displaying a placeholder (e.g., a neutral SVG placeholder with the store's brand color). E2E tests should not assert on specific image URLs.

---

### 3.11 DiscountSeeder

**Acme Fashion - 5 discount codes:**

| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |
|------|------|-----------|-------------|-----------|---------|-------------|-------------|-----------|--------|
| WELCOME10 | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | null | 3 | min_purchase_amount: 2000 (=20.00 EUR) | active |
| FLAT5 | code | fixed | 500 (=5.00 EUR) | 2025-01-01 | 2027-12-31 | null | 0 | (empty) | active |
| FREESHIP | code | free_shipping | 0 | 2025-01-01 | 2027-12-31 | null | 1 | (empty) | active |
| EXPIRED20 | code | percent | 20 (=20%) | 2024-01-01 | 2024-12-31 | null | 0 | (empty) | expired |
| MAXED | code | percent | 10 (=10%) | 2025-01-01 | 2027-12-31 | 5 | 5 | (empty) | active |


sed -n '1,220p' database/seeders/StoreSeeder.php


sed -n '1,220p' database/seeders/StoreDomainSeeder.php


sed -n '1,260p' database/seeders/CollectionSeeder.php


sed -n '1,240p' database/factories/ProductMediaFactory.php


sed -n '1,260p' database/seeders/CollectionSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Collection;
use App\Models\Store;
use Illuminate\Database\Seeder;

class CollectionSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        $collections = [
            ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Everyday cotton essentials.
'],
            ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Fresh pieces just landed.
'],
            ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Limited-time offers.
'],
            ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Finishing touches for every order.
'],
        ];

        foreach ($collections as $collection) {
            Collection::query()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'handle' => $collection['handle'],
                ],
                [
                    'title' => $collection['title'],
                    'description_html' => $collection['description_html'],
                    'type' => 'manual',
                    'status' => 'active',
                ],
            );
        }
    }
}


sed -n '1,220p' database/seeders/StoreDomainSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreDomain;
use Illuminate\Database\Seeder;

class StoreDomainSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        foreach (['shop.test', 'acme-fashion.test'] as $index => $hostname) {
            StoreDomain::query()->updateOrCreate(
                ['hostname' => $hostname],
                [
                    'store_id' => $store->getKey(),
                    'type' => 'storefront',
                    'is_primary' => $index === 0,
                    'tls_mode' => 'managed',
                ],
            );
        }
    }
}


sed -n '1,220p' database/seeders/StoreSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Organization;
use App\Models\Store;
use Illuminate\Database\Seeder;

class StoreSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $organization = Organization::query()->where('billing_email', 'billing@acme.test')->firstOrFail();

        Store::query()->updateOrCreate(
            ['handle' => 'acme-fashion'],
            [
                'organization_id' => $organization->getKey(),
                'name' => 'Acme Fashion',
                'status' => 'active',
                'default_currency' => 'EUR',
                'default_locale' => 'en',
                'timezone' => 'Europe/Berlin',
            ],
        );
    }
}


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

namespace Database\Seeders;

use App\Models\Organization;
use App\Models\Store;
use Illuminate\Database\Seeder;

class StoreSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $organization = Organization::query()->where('billing_email', 'billing@acme.test')->firstOrFail();

        Store::query()->updateOrCreate(
            ['handle' => 'acme-fashion'],
            [
                'organization_id' => $organization->getKey(),
                'name' => 'Acme Fashion',
                'status' => 'active',
                'default_currency' => 'EUR',
                'default_locale' => 'en',
                'timezone' => 'Europe/Berlin',
            ],
        );
    }
}


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

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreDomain;
use Illuminate\Database\Seeder;

class StoreDomainSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        foreach (['shop.test', 'acme-fashion.test'] as $index => $hostname) {
            StoreDomain::query()->updateOrCreate(
                ['hostname' => $hostname],
                [
                    'store_id' => $store->getKey(),
                    'type' => 'storefront',
                    'is_primary' => $index === 0,
                    'tls_mode' => 'managed',
                ],
            );
        }
    }
}


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

namespace Database\Seeders;

use App\Models\Collection;
use App\Models\Store;
use Illuminate\Database\Seeder;

class CollectionSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        $collections = [
            ['title' => 'T-Shirts', 'handle' => 't-shirts', 'description_html' => '
Everyday cotton essentials.
'],
            ['title' => 'New Arrivals', 'handle' => 'new-arrivals', 'description_html' => '
Fresh pieces just landed.
'],
            ['title' => 'Sale', 'handle' => 'sale', 'description_html' => '
Limited-time offers.
'],
            ['title' => 'Accessories', 'handle' => 'accessories', 'description_html' => '
Finishing touches for every order.
'],
        ];

        foreach ($collections as $collection) {
            Collection::query()->updateOrCreate(
                [
                    'store_id' => $store->getKey(),
                    'handle' => $collection['handle'],
                ],
                [
                    'title' => $collection['title'],
                    'description_html' => $collection['description_html'],
                    'type' => 'manual',
                    'status' => 'active',
                ],
            );
        }
    }
}


sed -n '1,240p' database/factories/ProductMediaFactory.php

<?php

namespace Database\Factories;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductMedia>
 */
class ProductMediaFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'type' => MediaType::Image,
            'storage_key' => 'products/'.fake()->uuid().'.jpg',
            'alt_text' => fake()->sentence(3),
            'width' => 1200,
            'height' => 1200,
            'mime_type' => 'image/jpeg',
            'byte_size' => fake()->numberBetween(50_000, 500_000),
            'position' => 0,
            'status' => MediaStatus::Ready,
        ];
    }

    public function processing(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => MediaStatus::Processing,
        ]);
    }

    public function video(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => MediaType::Video,
            'storage_key' => 'products/'.fake()->uuid().'.mp4',
            'mime_type' => 'video/mp4',
        ]);
    }
}


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

namespace Database\Factories;

use App\Enums\MediaStatus;
use App\Enums\MediaType;
use App\Models\Product;
use Illuminate\Database\Eloquent\Factories\Factory;

/**
 * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\ProductMedia>
 */
class ProductMediaFactory extends Factory
{
    /**
     * Define the model's default state.
     *
     * @return array<string, mixed>
     */
    public function definition(): array
    {
        return [
            'product_id' => Product::factory(),
            'type' => MediaType::Image,
            'storage_key' => 'products/'.fake()->uuid().'.jpg',
            'alt_text' => fake()->sentence(3),
            'width' => 1200,
            'height' => 1200,
            'mime_type' => 'image/jpeg',
            'byte_size' => fake()->numberBetween(50_000, 500_000),
            'position' => 0,
            'status' => MediaStatus::Ready,
        ];
    }

    public function processing(): static
    {
        return $this->state(fn (array $attributes): array => [
            'status' => MediaStatus::Processing,
        ]);
    }

    public function video(): static
    {
        return $this->state(fn (array $attributes): array => [
            'type' => MediaType::Video,
            'storage_key' => 'products/'.fake()->uuid().'.mp4',
            'mime_type' => 'video/mp4',
        ]);
    }
}


sed -n '1,220p' database/seeders/StoreSettingsSeeder.php


sed -n '1,220p' database/seeders/StoreUserSeeder.php


sed -n '1,220p' database/seeders/CustomerSeeder.php


sed -n '1,220p' database/seeders/StoreUserSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        DB::table('store_users')->updateOrInsert(
            [
                'store_id' => $store->getKey(),
                'user_id' => $user->getKey(),
            ],
            [
                'role' => 'owner',
                'created_at' => now(),
            ],
        );
    }
}


sed -n '1,220p' database/seeders/StoreSettingsSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Seeder;

class StoreSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        StoreSettings::query()->updateOrCreate(
            ['store_id' => $store->getKey()],
            [
                'settings_json' => [
                    'announcement' => [
                        'enabled' => true,
                        'text' => 'Free shipping on orders over 75.00 EUR',
                    ],
                    'checkout' => [
                        'guest_checkout_enabled' => true,
                    ],
                ],
            ],
        );
    }
}


sed -n '1,220p' database/seeders/CustomerSeeder.php

<?php

namespace Database\Seeders;

use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Seeder;

class CustomerSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        Customer::withoutGlobalScopes()->updateOrCreate(
            [
                'store_id' => $store->getKey(),
                'email' => 'customer@acme.test',
            ],
            [
                'name' => 'John Doe',
                'password' => 'password',
                'marketing_opt_in' => true,
            ],
        );
    }
}


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

namespace Database\Seeders;

use App\Models\Store;
use App\Models\StoreSettings;
use Illuminate\Database\Seeder;

class StoreSettingsSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        StoreSettings::query()->updateOrCreate(
            ['store_id' => $store->getKey()],
            [
                'settings_json' => [
                    'announcement' => [
                        'enabled' => true,
                        'text' => 'Free shipping on orders over 75.00 EUR',
                    ],
                    'checkout' => [
                        'guest_checkout_enabled' => true,
                    ],
                ],
            ],
        );
    }
}


Output:
Chunk ID: 01e869
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 183
Output:
<?php

namespace Database\Seeders;

use App\Models\Store;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;

class StoreUserSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();
        $user = User::query()->where('email', 'admin@acme.test')->firstOrFail();

        DB::table('store_users')->updateOrInsert(
            [
                'store_id' => $store->getKey(),
                'user_id' => $user->getKey(),
            ],
            [
                'role' => 'owner',
                'created_at' => now(),
            ],
        );
    }
}


Output:
Chunk ID: 355ad1
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 169
Output:
<?php

namespace Database\Seeders;

use App\Models\Customer;
use App\Models\Store;
use Illuminate\Database\Seeder;

class CustomerSeeder extends Seeder
{
    /**
     * Run the database seeds.
     */
    public function run(): void
    {
        $store = Store::query()->where('handle', 'acme-fashion')->firstOrFail();

        Customer::withoutGlobalScopes()->updateOrCreate(
            [
                'store_id' => $store->getKey(),
                'email' => 'customer@acme.test',
            ],
            [
                'name' => 'John Doe',
                'password' => 'password',
                'marketing_opt_in' => true,
            ],
        );
    }
}


Input:
*** Begin Patch
*** Update File: app/Services/ProductService.php
@@
 use App\Models\InventoryItem;
 use App\Models\Product;
+use App\Models\ProductOptionValue;
 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 InvalidArgumentException;
 use RuntimeException;
@@
     public function create(Store $store, array $data): Product
     {
         return DB::transaction(function () use ($store, $data): Product {
+            $requestedStatus = $this->requestedProductStatus($data) ?? ProductStatus::Draft;
+
             $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()),
+                'status' => ProductStatus::Draft,
+                'published_at' => null,
             ]);
@@
             foreach ($variants as $variantData) {
                 $this->createVariant($product, $store, $variantData);
             }

+            if ($requestedStatus !== ProductStatus::Draft) {
+                $this->transitionStatus($product->refresh(), $requestedStatus);
+            }
+
             return $product->refresh();
         });
     }
@@
     public function update(Product $product, array $data): Product
     {
         return DB::transaction(function () use ($product, $data): Product {
             $storeId = $product->store_id;
+            $requestedStatus = $this->requestedProductStatus($data);

             $product->fill(Arr::only($data, [
                 'title',
-                'status',
                 'description_html',
                 'vendor',
                 'product_type',
                 'tags',
-                'published_at',
             ]));
@@

             $product->save();
+            $product = $product->refresh();
+
+            if ($requestedStatus !== null && $requestedStatus !== $this->productStatus($product)) {
+                $this->transitionStatus($product, $requestedStatus);
+            }

             return $product->refresh();
         });
@@
         InventoryItem::withoutGlobalScopes()->updateOrCreate(
             ['variant_id' => $variant->getKey()],
             [
                 'store_id' => $store->getKey(),
@@
             ],
         );

+        $this->syncVariantOptionValues($product, $variant, $variantData);
+
         return $variant;
     }
+
+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function syncVariantOptionValues(Product $product, ProductVariant $variant, array $variantData): void
+    {
+        if (isset($variantData['option_value_ids'])) {
+            $valueIds = collect($variantData['option_value_ids'])
+                ->map(fn (mixed $valueId): int => (int) $valueId)
+                ->values()
+                ->all();
+
+            $validCount = ProductOptionValue::query()
+                ->whereIn('id', $valueIds)
+                ->whereHas('option', fn ($query) => $query->where('product_id', $product->getKey()))
+                ->count();
+
+            if ($validCount !== count($valueIds)) {
+                throw new InvalidArgumentException('Variant option values must belong to the product being created.');
+            }
+
+            $variant->optionValues()->sync($valueIds);
+
+            return;
+        }
+
+        if (! isset($variantData['options']) || ! is_array($variantData['options'])) {
+            return;
+        }
+
+        $valueIds = [];
+
+        foreach ($variantData['options'] as $optionName => $optionValue) {
+            $valueId = ProductOptionValue::query()
+                ->where('value', (string) $optionValue)
+                ->whereHas('option', function ($query) use ($product, $optionName): void {
+                    $query
+                        ->where('product_id', $product->getKey())
+                        ->where('name', (string) $optionName);
+                })
+                ->value('id');
+
+            if ($valueId === null) {
+                throw new InvalidArgumentException("Variant option selection [{$optionName}: {$optionValue}] is invalid for this product.");
+            }
+
+            $valueIds[] = (int) $valueId;
+        }
+
+        $variant->optionValues()->sync($valueIds);
+    }
@@
     private function productStatus(Product $product): ProductStatus
     {
         return $product->status instanceof ProductStatus
             ? $product->status
             : ProductStatus::from($product->status);
     }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    private function requestedProductStatus(array $data): ?ProductStatus
+    {
+        if (! array_key_exists('status', $data)) {
+            return null;
+        }
+
+        if ($data['status'] instanceof ProductStatus) {
+            return $data['status'];
+        }
+
+        return ProductStatus::from((string) $data['status']);
+    }
 }
*** End Patch


Success. Updated the following files:
M app/Services/ProductService.php

@@ -10,2 +10,3 @@
 use App\Models\Product;
+use App\Models\ProductOptionValue;
 use App\Models\ProductVariant;
@@ -16,2 +17,3 @@
 use Illuminate\Support\Facades\Schema;
+use InvalidArgumentException;
 use RuntimeException;
@@ -30,2 +32,4 @@
         return DB::transaction(function () use ($store, $data): Product {
+            $requestedStatus = $this->requestedProductStatus($data) ?? ProductStatus::Draft;
+
             $product = Product::query()->create([
@@ -33,3 +37,2 @@
                     'title',
-                    'status',
                     'description_html',
@@ -38,3 +41,2 @@
                     'tags',
-                    'published_at',
                 ]),
@@ -42,2 +44,4 @@
                 'handle' => $data['handle'] ?? $this->handles->generate($data['title'], 'products', $store->getKey()),
+                'status' => ProductStatus::Draft,
+                'published_at' => null,
             ]);
@@ -68,2 +72,6 @@

+            if ($requestedStatus !== ProductStatus::Draft) {
+                $this->transitionStatus($product->refresh(), $requestedStatus);
+            }
+
             return $product->refresh();
@@ -79,2 +87,3 @@
             $storeId = $product->store_id;
+            $requestedStatus = $this->requestedProductStatus($data);

@@ -82,3 +91,2 @@
                 'title',
-                'status',
                 'description_html',
@@ -87,3 +95,2 @@
                 'tags',
-                'published_at',
             ]));
@@ -97,3 +104,8 @@
             $product->save();
+            $product = $product->refresh();

+            if ($requestedStatus !== null && $requestedStatus !== $this->productStatus($product)) {
+                $this->transitionStatus($product, $requestedStatus);
+            }
+
             return $product->refresh();
@@ -175,2 +187,4 @@

+        $this->syncVariantOptionValues($product, $variant, $variantData);
+
         return $variant;
@@ -178,2 +192,53 @@

+    /**
+     * @param  array<string, mixed>  $variantData
+     */
+    private function syncVariantOptionValues(Product $product, ProductVariant $variant, array $variantData): void
+    {
+        if (isset($variantData['option_value_ids'])) {
+            $valueIds = collect($variantData['option_value_ids'])
+                ->map(fn (mixed $valueId): int => (int) $valueId)
+                ->values()
+                ->all();
+
+            $validCount = ProductOptionValue::query()
+                ->whereIn('id', $valueIds)
+                ->whereHas('option', fn ($query) => $query->where('product_id', $product->getKey()))
+                ->count();
+
+            if ($validCount !== count($valueIds)) {
+                throw new InvalidArgumentException('Variant option values must belong to the product being created.');
+            }
+
+            $variant->optionValues()->sync($valueIds);
+
+            return;
+        }
+
+        if (! isset($variantData['options']) || ! is_array($variantData['options'])) {
+            return;
+        }
+
+        $valueIds = [];
+
+        foreach ($variantData['options'] as $optionName => $optionValue) {
+            $valueId = ProductOptionValue::query()
+                ->where('value', (string) $optionValue)
+                ->whereHas('option', function ($query) use ($product, $optionName): void {
+                    $query
+                        ->where('product_id', $product->getKey())
+                        ->where('name', (string) $optionName);
+                })
+                ->value('id');
+
+            if ($valueId === null) {
+                throw new InvalidArgumentException("Variant option selection [{$optionName}: {$optionValue}] is invalid for this product.");
+            }
+
+            $valueIds[] = (int) $valueId;
+        }
+
+        $variant->optionValues()->sync($valueIds);
+    }
+
     private function assertCanActivate(Product $product): void
@@ -240,2 +305,18 @@
     }
+
+    /**
+     * @param  array<string, mixed>  $data
+     */
+    private function requestedProductStatus(array $data): ?ProductStatus
+    {
+        if (! array_key_exists('status', $data)) {
+            return null;
+        }
+
+        if ($data['status'] instanceof ProductStatus) {
+            return $data['status'];
+        }
+
+        return ProductStatus::from((string) $data['status']);
+    }
 }


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


Input:
*** Begin Patch
*** Update File: app/Models/ProductVariant.php
@@
 use App\Enums\InventoryPolicy;
 use App\Enums\VariantStatus;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 use Illuminate\Database\Eloquent\Relations\HasOne;
+use RuntimeException;
@@
     protected static function booted(): void
     {
+        static::addGlobalScope('current_store', function (Builder $builder): void {
+            if (! app()->bound('current_store')) {
+                return;
+            }
+
+            $store = app('current_store');
+
+            if (! $store instanceof Store) {
+                return;
+            }
+
+            $builder->whereHas('product', function (Builder $query) use ($store): void {
+                $query
+                    ->withoutGlobalScopes()
+                    ->where('store_id', $store->getKey());
+            });
+        });
+
+        static::saving(function (ProductVariant $variant): void {
+            $variant->assertSkuIsUniqueForStore();
+        });
+
         static::created(function (ProductVariant $variant): void {
             if ($variant->inventoryItem()->exists()) {
                 return;
@@
         });
     }
+
+    private function assertSkuIsUniqueForStore(): void
+    {
+        $sku = trim((string) $this->sku);
+
+        if ($sku === '') {
+            return;
+        }
+
+        $storeId = Product::withoutGlobalScopes()
+            ->whereKey($this->product_id)
+            ->value('store_id');
+
+        if ($storeId === null) {
+            return;
+        }
+
+        $query = self::withoutGlobalScopes()
+            ->where('sku', $sku)
+            ->whereHas('product', function (Builder $query) use ($storeId): void {
+                $query
+                    ->withoutGlobalScopes()
+                    ->where('store_id', $storeId);
+            });
+
+        if ($this->exists) {
+            $query->whereKeyNot($this->getKey());
+        }
+
+        if ($query->exists()) {
+            throw new RuntimeException("The SKU [{$sku}] is already used in this store.");
+        }
+    }
*** End Patch