output:
Chunk ID: 66d2fd
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 6538
Output:
specs/01-DATABASE-SCHEMA.md:53:| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended') | |
specs/01-DATABASE-SCHEMA.md:64:- `idx_stores_status` on (`status`) - Filtering stores by active/suspended status
specs/01-DATABASE-SCHEMA.md:100:| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:113:- `idx_users_status` on (`status`) - Filtering users by active/disabled
specs/01-DATABASE-SCHEMA.md:165:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:178:- `idx_products_store_status` on (`store_id`, `status`) - Filtering products by status within a store
specs/01-DATABASE-SCHEMA.md:240:| status | TEXT | No | 'active' | CHECK IN ('active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:256:### variant_option_values
specs/01-DATABASE-SCHEMA.md:269:- `idx_variant_option_values_value_id` on (`product_option_value_id`) - Reverse lookup: find all variants with a given option value
specs/01-DATABASE-SCHEMA.md:309:| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'archived') | |
specs/01-DATABASE-SCHEMA.md:317:- `idx_collections_store_status` on (`store_id`, `status`) - Filtering collections by status
specs/01-DATABASE-SCHEMA.md:356:| status | TEXT | No | 'processing' | CHECK IN ('processing', 'ready', 'failed') | Processing status |
specs/01-DATABASE-SCHEMA.md:363:- `idx_product_media_status` on (`status`) - Finding media by processing status
specs/01-DATABASE-SCHEMA.md:379:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published') | |
specs/01-DATABASE-SCHEMA.md:387:- `idx_themes_store_status` on (`store_id`, `status`) - Finding published/draft themes per store
specs/01-DATABASE-SCHEMA.md:434:| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
specs/01-DATABASE-SCHEMA.md:443:- `idx_pages_store_status` on (`store_id`, `status`) - Filtering pages by status
specs/01-DATABASE-SCHEMA.md:539:| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
specs/01-DATABASE-SCHEMA.md:547:- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)
specs/01-DATABASE-SCHEMA.md:587:| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
specs/01-DATABASE-SCHEMA.md:605:- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status
specs/01-DATABASE-SCHEMA.md:689:| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:697:- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
specs/01-DATABASE-SCHEMA.md:762:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |
specs/01-DATABASE-SCHEMA.md:763:| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |
specs/01-DATABASE-SCHEMA.md:764:| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
specs/01-DATABASE-SCHEMA.md:783:- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status
specs/01-DATABASE-SCHEMA.md:784:- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status
specs/01-DATABASE-SCHEMA.md:785:- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status
specs/01-DATABASE-SCHEMA.md:829:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |
specs/01-DATABASE-SCHEMA.md:840:- `idx_payments_status` on (`status`) - Filtering payments by status
specs/01-DATABASE-SCHEMA.md:857:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |
specs/01-DATABASE-SCHEMA.md:865:- `idx_refunds_status` on (`status`) - Filtering refunds by status
specs/01-DATABASE-SCHEMA.md:877:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |
specs/01-DATABASE-SCHEMA.md:887:- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status
specs/01-DATABASE-SCHEMA.md:979:| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:984:- `idx_apps_status` on (`status`) - Filtering apps by status
specs/01-DATABASE-SCHEMA.md:998:| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |
specs/01-DATABASE-SCHEMA.md:1060:| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |
specs/01-DATABASE-SCHEMA.md:1080:| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |
specs/01-DATABASE-SCHEMA.md:1089:- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry
specs/01-DATABASE-SCHEMA.md:1224:        TEXT status
specs/01-DATABASE-SCHEMA.md:1238:        TEXT status
specs/01-DATABASE-SCHEMA.md:1260:    product_variants }o--o{ product_option_values : "variant_option_values"
specs/01-DATABASE-SCHEMA.md:1271:        TEXT status
specs/01-DATABASE-SCHEMA.md:1290:        TEXT status
specs/01-DATABASE-SCHEMA.md:1306:        TEXT status
specs/01-DATABASE-SCHEMA.md:1314:        TEXT status
specs/01-DATABASE-SCHEMA.md:1333:        TEXT status
specs/01-DATABASE-SCHEMA.md:1350:        TEXT status
specs/01-DATABASE-SCHEMA.md:1399:        TEXT status
specs/01-DATABASE-SCHEMA.md:1407:        TEXT status
specs/01-DATABASE-SCHEMA.md:1408:        TEXT financial_status
specs/01-DATABASE-SCHEMA.md:1409:        TEXT fulfillment_status
specs/01-DATABASE-SCHEMA.md:1417:        TEXT status
specs/01-DATABASE-SCHEMA.md:1423:        TEXT status
specs/01-DATABASE-SCHEMA.md:1458:        TEXT status
specs/01-DATABASE-SCHEMA.md:1464:        TEXT status
specs/01-DATABASE-SCHEMA.md:1476:        TEXT status
specs/01-DATABASE-SCHEMA.md:1569:        variant_option_values
specs/01-DATABASE-SCHEMA.md:1628:    product_variants --> variant_option_values
specs/01-DATABASE-SCHEMA.md:1629:    product_option_values --> variant_option_values
specs/01-DATABASE-SCHEMA.md:1648:- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`
specs/01-DATABASE-SCHEMA.md:1655:1. **Tenant-scoped composite indexes** - Most queries filter by `store_id` first, then by status, handle, or date. Composite indexes like (`store_id`, `status`) and (`store_id`, `handle`) serve these access patterns efficiently.
specs/01-DATABASE-SCHEMA.md:1661:4. **Composite primary keys for pivot tables** - Tables like `store_users`, `collection_products`, `variant_option_values`, and `analytics_daily` use composite primary keys to naturally enforce uniqueness and serve as their own covering index for the most common query pattern.
specs/01-DATABASE-SCHEMA.md:1674:| 2 - Catalog, Collections, Inventory, Media | products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media | 9 |
specs/07-SEEDERS-AND-TEST-DATA.md:101:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:110:| `suspended` | status = "suspended" |
specs/07-SEEDERS-AND-TEST-DATA.md:149:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:156:| `disabled` | status = "disabled" |
specs/07-SEEDERS-AND-TEST-DATA.md:171:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:182:| `draft` | status = "draft", published_at = null |
specs/07-SEEDERS-AND-TEST-DATA.md:183:| `archived` | status = "archived" |
specs/07-SEEDERS-AND-TEST-DATA.md:189:| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |
specs/07-SEEDERS-AND-TEST-DATA.md:190:| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |
specs/07-SEEDERS-AND-TEST-DATA.md:194:### 2.6 ProductVariantFactory
specs/07-SEEDERS-AND-TEST-DATA.md:196:**Model:** ProductVariant
specs/07-SEEDERS-AND-TEST-DATA.md:212:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:221:| `archived` | status = "archived" |
specs/07-SEEDERS-AND-TEST-DATA.md:234:| variant_id | Creates a new ProductVariant via its factory |
specs/07-SEEDERS-AND-TEST-DATA.md:262:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:268:| `draft` | status = "draft" |
specs/07-SEEDERS-AND-TEST-DATA.md:269:| `archived` | status = "archived" |
specs/07-SEEDERS-AND-TEST-DATA.md:286:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:293:| `converted` | status = "converted" |
specs/07-SEEDERS-AND-TEST-DATA.md:294:| `abandoned` | status = "abandoned" |
specs/07-SEEDERS-AND-TEST-DATA.md:307:| variant_id | Creates a new ProductVariant via its factory |
specs/07-SEEDERS-AND-TEST-DATA.md:329:| status | "started" |
specs/07-SEEDERS-AND-TEST-DATA.md:344:| `completed` | status = "completed" |
specs/07-SEEDERS-AND-TEST-DATA.md:345:| `expired` | status = "expired", expires_at = 1 hour ago |
specs/07-SEEDERS-AND-TEST-DATA.md:364:| status | "paid" |
specs/07-SEEDERS-AND-TEST-DATA.md:365:| financial_status | "paid" |
specs/07-SEEDERS-AND-TEST-DATA.md:366:| fulfillment_status | "unfulfilled" |
specs/07-SEEDERS-AND-TEST-DATA.md:382:| `pending` | status = "pending", financial_status = "pending" |
specs/07-SEEDERS-AND-TEST-DATA.md:383:| `pendingBankTransfer` | status = "pending", financial_status = "pending", payment_method = "bank_transfer" |
specs/07-SEEDERS-AND-TEST-DATA.md:384:| `fulfilled` | status = "fulfilled", fulfillment_status = "fulfilled" |
specs/07-SEEDERS-AND-TEST-DATA.md:385:| `cancelled` | status = "cancelled", financial_status = "refunded" |
specs/07-SEEDERS-AND-TEST-DATA.md:386:| `refunded` | status = "refunded", financial_status = "refunded" |
specs/07-SEEDERS-AND-TEST-DATA.md:387:| `partiallyFulfilled` | fulfillment_status = "partial" |
specs/07-SEEDERS-AND-TEST-DATA.md:404:| variant_id | Creates a new ProductVariant via its factory |
specs/07-SEEDERS-AND-TEST-DATA.md:429:| status | "captured" |
specs/07-SEEDERS-AND-TEST-DATA.md:438:| `pending` | status = "pending" |
specs/07-SEEDERS-AND-TEST-DATA.md:439:| `failed` | status = "failed" |
specs/07-SEEDERS-AND-TEST-DATA.md:440:| `refunded` | status = "refunded" |
specs/07-SEEDERS-AND-TEST-DATA.md:459:| status | "processed" |
specs/07-SEEDERS-AND-TEST-DATA.md:466:| `pending` | status = "pending", provider_refund_id = null |
specs/07-SEEDERS-AND-TEST-DATA.md:467:| `failed` | status = "failed" |
specs/07-SEEDERS-AND-TEST-DATA.md:480:| status | "shipped" |
specs/07-SEEDERS-AND-TEST-DATA.md:490:| `pending` | status = "pending", tracking_number = null, tracking_url = null, shipped_at = null |
specs/07-SEEDERS-AND-TEST-DATA.md:491:| `delivered` | status = "delivered" |
specs/07-SEEDERS-AND-TEST-DATA.md:573:| status | "active" |
specs/07-SEEDERS-AND-TEST-DATA.md:583:| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = "expired" |
specs/07-SEEDERS-AND-TEST-DATA.md:586:| `draft` | status = "draft" |
specs/07-SEEDERS-AND-TEST-DATA.md:587:| `disabled` | status = "disabled", starts_at = past date |
specs/07-SEEDERS-AND-TEST-DATA.md:642:| status | "published" |
specs/07-SEEDERS-AND-TEST-DATA.md:649:| `draft` | status = "draft", published_at = null |
specs/07-SEEDERS-AND-TEST-DATA.md:665:| status | "published" |
specs/07-SEEDERS-AND-TEST-DATA.md:672:| `draft` | status = "draft", published_at = null |
specs/07-SEEDERS-AND-TEST-DATA.md:673:| `archived` | status = "archived" |
specs/07-SEEDERS-AND-TEST-DATA.md:770:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:781:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:934: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.
specs/07-SEEDERS-AND-TEST-DATA.md:943:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:981:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1015:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1035:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1055:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1075:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1095:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1115:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1135:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1155:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1175:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1195:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1215:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1235:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1255:| status | **draft** |
specs/07-SEEDERS-AND-TEST-DATA.md:1277:| status | **archived** |
specs/07-SEEDERS-AND-TEST-DATA.md:1299:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1321:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1343:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1371:| status | active |
specs/07-SEEDERS-AND-TEST-DATA.md:1431:**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.
specs/07-SEEDERS-AND-TEST-DATA.md:1439:| Code | Type | Value Type | value_amount | starts_at | ends_at | usage_limit | usage_count | rules_json | status |
specs/07-SEEDERS-AND-TEST-DATA.md:1513:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1524:Payment: mock, method "credit_card", provider_payment_id "mock_test_order1001", status "captured", amount 5497
specs/07-SEEDERS-AND-TEST-DATA.md:1536:| status / financial / fulfillment | fulfilled / paid / fulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1550:Fulfillment: status "delivered", tracking company "DHL", tracking number "DHL1234567890", shipped 8 days ago. Fulfillment lines cover all order lines at full quantity.
specs/07-SEEDERS-AND-TEST-DATA.md:1560:| status / financial / fulfillment | paid / paid / partial |
specs/07-SEEDERS-AND-TEST-DATA.md:1574:Fulfillment: status "shipped", DHL, "DHL9876543210", shipped 3 days ago. Fulfillment lines: **only the Jeans line (qty 1)**. The Belt is not yet shipped.
specs/07-SEEDERS-AND-TEST-DATA.md:1584:| status / financial / fulfillment | cancelled / refunded / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1595:Payment: mock, method "credit_card", "mock_test_order1004", status "refunded", amount 2998
specs/07-SEEDERS-AND-TEST-DATA.md:1597:Refund: amount 2998, reason "Customer requested cancellation", status "processed", provider_refund_id "mock_re_test_order1004"
specs/07-SEEDERS-AND-TEST-DATA.md:1607:| status / financial / fulfillment | pending / pending / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1618:Payment: mock, method "bank_transfer", "mock_test_order1005", status "pending", amount 3998
specs/07-SEEDERS-AND-TEST-DATA.md:1630:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1651:| status / financial / fulfillment | fulfilled / paid / fulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1675:| status / financial / fulfillment | paid / partially_refunded / fulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1691:Refund (partial - for Graphic Print Tee only): amount 2999, reason "Item returned", status "processed", provider_refund_id "mock_re_test_order1008"
specs/07-SEEDERS-AND-TEST-DATA.md:1701:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1723:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1744:| status / financial / fulfillment | paid / paid / fulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1767:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1788:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1810:| status / financial / fulfillment | paid / paid / fulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1833:| status / financial / fulfillment | paid / paid / unfulfilled |
specs/07-SEEDERS-AND-TEST-DATA.md:1849:**Test purpose:** Tests a bank transfer order that has been confirmed by the admin (payment status = captured). Contrasts with #1005 which is still pending.
specs/07-SEEDERS-AND-TEST-DATA.md:1893:| status | published |
specs/07-SEEDERS-AND-TEST-DATA.md:1921:| status | published |
specs/07-SEEDERS-AND-TEST-DATA.md:1943:All pages: status = "published", published_at = 3 months ago.
specs/07-SEEDERS-AND-TEST-DATA.md:2208:After creating variants for a product with options, the seeder must also insert rows into the `variant_option_values` pivot table to link each variant to its specific option value IDs. The process is:
specs/07-SEEDERS-AND-TEST-DATA.md:2214:5. For each variant, insert into `variant_option_values` mapping the variant ID to the IDs of the option values it represents (e.g. variant "S/White" maps to Size="S" and Color="White" option value IDs)
specs/05-BUSINESS-LOGIC.md:35:6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.
specs/05-BUSINESS-LOGIC.md:115:**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`
specs/05-BUSINESS-LOGIC.md:143:**Service:** `App\Services\ProductService`
specs/05-BUSINESS-LOGIC.md:146:- Dispatch `ProductStatusChanged` event after successful transition.
specs/05-BUSINESS-LOGIC.md:173:            ARCHIVE variant (set status = 'archived')
specs/05-BUSINESS-LOGIC.md:294:2. Product has status = `active`.
specs/05-BUSINESS-LOGIC.md:295:3. Variant has status = `active`.
specs/05-BUSINESS-LOGIC.md:341:- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.
specs/05-BUSINESS-LOGIC.md:468:5. Transition status to `addressed`.
specs/05-BUSINESS-LOGIC.md:477:- **Skip condition:** If no cart lines have `requires_shipping = true` on their variant, skip this step. Set status to `shipping_selected` with shipping_method_id = null and shipping_amount = 0.
specs/05-BUSINESS-LOGIC.md:484:5. Transition status to `shipping_selected`.
specs/05-BUSINESS-LOGIC.md:500:4. Transition status to `payment_selected`.
specs/05-BUSINESS-LOGIC.md:526:    4. DETERMINE order statuses based on payment method:
specs/05-BUSINESS-LOGIC.md:541:       - status = orderStatus, financial_status = financialStatus
specs/05-BUSINESS-LOGIC.md:542:       - fulfillment_status = 'unfulfilled'
specs/05-BUSINESS-LOGIC.md:555:       - status = paymentStatus, amount = order total
specs/05-BUSINESS-LOGIC.md:565:    10. SET cart status = 'converted'
specs/05-BUSINESS-LOGIC.md:567:    11. SET checkout status = 'completed'
specs/05-BUSINESS-LOGIC.md:582:**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.
specs/05-BUSINESS-LOGIC.md:585:1. If inventory was reserved (status was `payment_selected`): release all reserved inventory via InventoryService.release.
specs/05-BUSINESS-LOGIC.md:586:2. Transition status to `expired`.
specs/05-BUSINESS-LOGIC.md:684:| `discount_expired` | Past `ends_at` date or status is not active |
specs/05-BUSINESS-LOGIC.md:915:- The checkout transitions directly past `shipping_selected` (set status to `shipping_selected` with shipping_method_id = null).
specs/05-BUSINESS-LOGIC.md:953:| `bank_transfer` | No charge at checkout. Order created with `financial_status = pending`. Admin manually confirms payment receipt. | Deferred - admin confirms payment |
specs/05-BUSINESS-LOGIC.md:961:| `4242 4242 4242 4242` | Success | `success = true`, status = `captured` |
specs/05-BUSINESS-LOGIC.md:964:| Any other valid-looking number | Success | `success = true`, status = `captured` |
specs/05-BUSINESS-LOGIC.md:985:            Backend->>Backend: Create order (financial_status = paid)
specs/05-BUSINESS-LOGIC.md:993:        MockPSP-->>Backend: PaymentResult (success = true, status = pending)
specs/05-BUSINESS-LOGIC.md:994:        Backend->>Backend: Create order (financial_status = pending)
specs/05-BUSINESS-LOGIC.md:1030:Admin users can confirm that a bank transfer payment has been received via a "Confirm Payment" button on the order detail page (visible only when `payment_method = bank_transfer` AND `financial_status = pending`).
specs/05-BUSINESS-LOGIC.md:1034:2. Backend updates `payments.status` from `pending` to `captured`.
specs/05-BUSINESS-LOGIC.md:1035:3. Backend updates `orders.financial_status` from `pending` to `paid`.
specs/05-BUSINESS-LOGIC.md:1036:4. Backend updates `orders.status` from `pending` to `paid`.
specs/05-BUSINESS-LOGIC.md:1038:6. If all order lines are digital (`requires_shipping = false`): auto-create fulfillment with `status = delivered` (see Section 11.7).
specs/05-BUSINESS-LOGIC.md:1046:- Finds orders where `payment_method = bank_transfer` AND `financial_status = pending` AND `placed_at < (now - configurable days)`.
specs/05-BUSINESS-LOGIC.md:1051:2. Update `orders.financial_status` to `voided`.
specs/05-BUSINESS-LOGIC.md:1052:3. Update `orders.status` to `cancelled`.
specs/05-BUSINESS-LOGIC.md:1053:4. Update `payments.status` to `failed`.
specs/05-BUSINESS-LOGIC.md:1073:| Payment Method | `orders.status` | `orders.financial_status` | Inventory Action | Payment Record Status |
specs/05-BUSINESS-LOGIC.md:1088:Three independent status dimensions tracked on each order:
specs/05-BUSINESS-LOGIC.md:1090:**Order status** (`orders.status`):
specs/05-BUSINESS-LOGIC.md:1100:**Financial status** (`orders.financial_status`):
specs/05-BUSINESS-LOGIC.md:1111:**Fulfillment status** (`orders.fulfillment_status`):
specs/05-BUSINESS-LOGIC.md:1148:    4. CREATE Refund record with status = 'pending'
specs/05-BUSINESS-LOGIC.md:1151:       UPDATE refund status = 'processed'
specs/05-BUSINESS-LOGIC.md:1155:           SET order.financial_status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1156:           SET order.status = 'refunded'
specs/05-BUSINESS-LOGIC.md:1158:           SET order.financial_status = 'partially_refunded'
specs/05-BUSINESS-LOGIC.md:1173:**CRITICAL:** A fulfillment cannot be created unless the order's `financial_status` is `paid` or `partially_refunded`. This prevents shipping goods before payment is confirmed.
specs/05-BUSINESS-LOGIC.md:1175:| `financial_status` | Fulfillment Allowed? | Reason |
specs/05-BUSINESS-LOGIC.md:1196:    1. GUARD: Verify order.financial_status IN ('paid', 'partially_refunded')
specs/05-BUSINESS-LOGIC.md:1204:    3. CREATE Fulfillment record with status = 'pending' and tracking data
specs/05-BUSINESS-LOGIC.md:1208:    5. Determine new fulfillment status:
specs/05-BUSINESS-LOGIC.md:1215:           SET order.fulfillment_status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1216:           SET order.status = 'fulfilled'
specs/05-BUSINESS-LOGIC.md:1218:           SET order.fulfillment_status = 'partial'
specs/05-BUSINESS-LOGIC.md:1225:Fulfillment status transitions are triggered by explicit admin button clicks. There are no automatic transitions for physical products.
specs/05-BUSINESS-LOGIC.md:1242:| "Mark as shipped" | `fulfillment.status = pending` | Transitions to `shipped`, sets `shipped_at`, dispatches `FulfillmentShipped` event, sends tracking email |
specs/05-BUSINESS-LOGIC.md:1243:| "Mark as delivered" | `fulfillment.status = shipped` | Transitions to `delivered`, dispatches `FulfillmentDelivered` event |
specs/05-BUSINESS-LOGIC.md:1270:   - `status = 'delivered'` (skip pending and shipped)
specs/05-BUSINESS-LOGIC.md:1273:   - Set `order.fulfillment_status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1274:   - Set `order.status = 'fulfilled'`
specs/05-BUSINESS-LOGIC.md:1283:**CSV columns:** `order_number`, `created_at`, `status`, `financial_status`, `fulfillment_status`, `customer_email`, `customer_name`, `subtotal_amount`, `discount_amount`, `shipping_amount`, `tax_amount`, `total_amount`, `currency`, `shipping_method`, `tracking_number`
specs/05-BUSINESS-LOGIC.md:1379:    Listener->>Listener: Query webhook_subscriptions for store_id + event_type where status = active
specs/05-BUSINESS-LOGIC.md:1421:- If a subscription's endpoint fails 5 consecutive times: automatically set `webhook_subscriptions.status = 'paused'`.
specs/05-BUSINESS-LOGIC.md:1497:    B --> C[Create product_media record with status = processing]
specs/05-BUSINESS-LOGIC.md:1500:    E -->|Yes| F[Store resized versions, set status = ready]
specs/05-BUSINESS-LOGIC.md:1501:    E -->|No| G[Set status = failed, log error, keep original]
specs/05-BUSINESS-LOGIC.md:1521:- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
specs/05-BUSINESS-LOGIC.md:1522:- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
specs/05-BUSINESS-LOGIC.md:1578:    5. Exclude products with status != 'active' or published_at IS NULL
specs/05-BUSINESS-LOGIC.md:1600:| `ProductStatusChanged` | Sync FTS5 index if transitioning to/from active |
specs/05-BUSINESS-LOGIC.md:1604:| `OrderPaid` | Update order status, commit inventory (bank transfer confirmation), auto-fulfill digital products |
specs/05-BUSINESS-LOGIC.md:1605:| `FulfillmentCreated` | Update order fulfillment status |
specs/05-BUSINESS-LOGIC.md:1638:| ProductService | `App\Services` | Product CRUD, status transitions |
specs/05-BUSINESS-LOGIC.md:1652:| FulfillmentService | `App\Services` | Fulfillment creation and status management |
specs/05-BUSINESS-LOGIC.md:1676:| ProductStatus | Draft (`draft`), Active (`active`), Archived (`archived`) |
specs/05-BUSINESS-LOGIC.md:1705:| PaymentResult | success (bool), referenceId (string), status (string: pending/captured/failed), errorCode (nullable string), errorMessage (nullable string) | Mock payment processing result |
specs/05-BUSINESS-LOGIC.md:1707:| RefundResult | success (bool), providerRefundId (string), status (string) | Refund processing result |


output:
Chunk ID: f0d05b
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 808
Output:
      "properties": { ... }
    }
  ]
}
```

**Event fields:**

| Field | Type | Required | Description |
|---|---|---|---|
| `type` | string | Yes | One of: `page_view`, `product_view`, `add_to_cart`, `checkout_started`, `checkout_completed` |
| `session_id` | string | Yes | Client session identifier |
| `client_event_id` | string | Yes | Client-generated unique ID for deduplication |
| `occurred_at` | string (ISO-8601) | Yes | When the event occurred on the client |
| `properties` | object | No | Event-specific data (product_id, variant_id, etc.) |

**Deduplication:** Events are deduplicated by `client_event_id` within the store scope. If a `client_event_id` has already been recorded for the current store (enforced by the unique index on `store_id, client_event_id`), the duplicate event is silently dropped.

**Rate limiting:** 60 requests per minute per IP address, using Laravel's throttle middleware.

### 14.2 Aggregation

**Job:** `App\Jobs\AggregateAnalytics`

- Schedule: daily at 01:00 UTC
- Aggregates the previous day's raw `analytics_events` into `analytics_daily`.

**Aggregated metrics:**

| Metric | Calculation |
|---|---|
| `orders_count` | Count of `checkout_completed` events for the day |
| `revenue_amount` | Sum of order totals from `checkout_completed` event properties |
| `aov_amount` | `revenue_amount / orders_count` (integer division, 0 if no orders) |
| `visits_count` | Count of distinct `session_id` values with `page_view` events |
| `add_to_cart_count` | Count of `add_to_cart` events |
| `checkout_started_count` | Count of `checkout_started` events |

---

## 15. Media Processing

### 15.1 Upload Flow

```mermaid
flowchart TD
    A[Admin uploads file via Livewire] --> B[Store original in storage/app/public/media/originals/]
    B --> C[Create product_media record with status = processing]
    C --> D[Dispatch ProcessMediaUpload job to queue]
    D --> E{Processing succeeds?}
    E -->|Yes| F[Store resized versions, set status = ready]
    E -->|No| G[Set status = failed, log error, keep original]
```

### 15.2 Processing

**Job:** `App\Jobs\ProcessMediaUpload`

**Resize targets:**

| Key | Max Dimensions | Use Case |
|---|---|---|
| `thumbnail` | 150x150 px | Admin grid, small previews |
| `small` | 300x300 px | Product cards, listings |
| `medium` | 600x600 px | Product detail page |
| `large` | 1200x1200 px | Full-size product image, zoom |

**Processing rules:**
- Maintain aspect ratio (contain within target dimensions, no cropping).
- Generate WebP version alongside the original format if the server supports it.
- Store all sizes with predictable path keys: `media/{product_id}/{media_id}/{size}.{ext}`
- Update the `product_media` record with: `width`, `height`, `mime_type`, `byte_size` from the original, and `status = 'ready'`.
- On failure: set `status = 'failed'` and log the error. Do not delete the original file.
- Maximum 3 retry attempts before permanent failure.

### 15.3 Cleanup

When a `product_media` record is deleted, a model event listener removes all associated files from storage (all sizes and the original).

---

## 16. Search (SQLite FTS5)

### 16.1 Index Setup

Create an FTS5 virtual table named `products_fts` with these columns:


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 |


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


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

type: read

aggregated_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

---

status: completed


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

---


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


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

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


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.


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


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


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


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


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

aggregated_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;
        }
    }
}


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

aggregated_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,
        ];
    }
}


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

aggregated_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,
        ];
    }
}


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

type: search

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

query: function check|check\(

path: Schema

status: failed


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


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


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

type: unknown

aggregated_output:
11:exif
13:fileinfo
16:gd
22:imagick


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


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


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

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