Script completed
Wall time 0.1 seconds
Output:

| title | TEXT | No | - | - | Page title |
| handle | TEXT | No | - | UNIQUE per (store_id, handle) | URL-friendly slug |
| body_html | TEXT | Yes | NULL | - | Rich-text content |
| status | TEXT | No | 'draft' | CHECK IN ('draft', 'published', 'archived') | |
| published_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

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

---

### navigation_menus

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

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

**Indexes:**

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

---

### navigation_items

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

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| menu_id | INTEGER | No | - | FK -> navigation_menus(id) ON DELETE CASCADE | Parent menu |
| type | TEXT | No | 'link' | CHECK IN ('link', 'page', 'collection', 'product') | Item type |
| label | TEXT | No | - | - | Display label |
| url | TEXT | Yes | NULL | - | Direct URL (used when type = 'link') |
| resource_id | INTEGER | Yes | NULL | - | FK to pages/collections/products (polymorphic) |
| position | INTEGER | No | 0 | - | Display ordering |

**Indexes:**

- `idx_navigation_items_menu_id` on (`menu_id`) - FK lookup, listing items per menu
- `idx_navigation_items_menu_position` on (`menu_id`, `position`) - Ordered item listing

**Notes:** When `type` is `link`, the `url` field is used directly. When `type` is `page`, `collection`, or `product`, the `resource_id` points to the respective table's `id`. The URL is resolved at render time.

---

## Epic 4 - Search

### search_settings

Per-store search configuration (synonyms, stop words).

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| synonyms_json | TEXT | No | '[]' | - | JSON array of synonym groups |
| stop_words_json | TEXT | No | '[]' | - | JSON array of stop words |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### search_queries

Search query log for analytics and autocomplete.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| query | TEXT | No | - | - | Search query text |
| filters_json | TEXT | Yes | NULL | - | JSON object of applied filters |
| results_count | INTEGER | No | 0 | - | Number of results returned |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_search_queries_store_id` on (`store_id`) - FK lookup
- `idx_search_queries_store_created` on (`store_id`, `created_at`) - Time-range queries per store
- `idx_search_queries_store_query` on (`store_id`, `query`) - Autocomplete and popular search lookups

---

## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes

### carts

Shopping cart, optionally linked to a customer.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest carts |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| cart_version | INTEGER | No | 1 | - | Optimistic concurrency version counter |
| status | TEXT | No | 'active' | CHECK IN ('active', 'converted', 'abandoned') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_carts_store_id` on (`store_id`) - FK lookup
- `idx_carts_customer_id` on (`customer_id`) - Finding carts by customer
- `idx_carts_store_status` on (`store_id`, `status`) - Filtering carts by status (e.g. abandoned cart recovery)

**Notes:** `cart_version` is incremented on every mutation and used for optimistic concurrency control.

---

### cart_lines

Individual line items within a cart.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Parent cart |
| variant_id | INTEGER | No | - | FK -> product_variants(id) ON DELETE CASCADE | Variant being purchased |
| quantity | INTEGER | No | 1 | - | Number of units |
| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
| line_subtotal_amount | INTEGER | No | 0 | - | unit_price * quantity |
| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
| line_total_amount | INTEGER | No | 0 | - | subtotal - discount |

**Indexes:**

- `idx_cart_lines_cart_id` on (`cart_id`) - FK lookup, listing lines per cart
- `idx_cart_lines_cart_variant` UNIQUE on (`cart_id`, `variant_id`) - Prevents duplicate variants in the same cart

**Notes:** All `_amount` columns are in minor units (cents). `line_subtotal_amount = unit_price_amount * quantity`. `line_total_amount = line_subtotal_amount - line_discount_amount`.

---

### checkouts

Checkout session progressing through address, shipping, payment steps.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| cart_id | INTEGER | No | - | FK -> carts(id) ON DELETE CASCADE | Source cart |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest checkout |
| status | TEXT | No | 'started' | CHECK IN ('started', 'addressed', 'shipping_selected', 'payment_selected', 'completed', 'expired') | |
| payment_method | TEXT | Yes | NULL | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Selected payment method |
| email | TEXT | Yes | NULL | - | Contact email |
| shipping_address_json | TEXT | Yes | NULL | - | JSON address object |
| billing_address_json | TEXT | Yes | NULL | - | JSON address object |
| shipping_method_id | INTEGER | Yes | NULL | - | Selected shipping rate |
| discount_code | TEXT | Yes | NULL | - | Applied discount code |
| tax_provider_snapshot_json | TEXT | Yes | NULL | - | Full tax calculation response for audit |
| totals_json | TEXT | Yes | NULL | - | Snapshot: subtotal, discount, shipping, tax, total |
| expires_at | TEXT | Yes | NULL | - | ISO-8601 expiration timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_checkouts_store_id` on (`store_id`) - FK lookup
- `idx_checkouts_cart_id` on (`cart_id`) - Finding checkout for a cart
- `idx_checkouts_customer_id` on (`customer_id`) - Listing checkouts per customer
- `idx_checkouts_status` on (`store_id`, `status`) - Filtering checkouts by status
- `idx_checkouts_expires_at` on (`expires_at`) - Cleanup of expired checkouts

**Notes:** `totals_json` contains a snapshot: `{"subtotal": 5000, "discount": 500, "shipping": 799, "tax": 430, "total": 5729}`. `tax_provider_snapshot_json` stores the full tax calculation response for audit purposes.

---

### shipping_zones

Geographic shipping zones for 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 |
| name | TEXT | No | - | - | Zone display name |
| countries_json | TEXT | No | '[]' | - | JSON array of ISO 3166-1 alpha-2 codes, e.g. ["US","CA"] |
| regions_json | TEXT | No | '[]' | - | JSON array of region/state codes, e.g. ["US-NY","US-CA"] |

**Indexes:**

- `idx_shipping_zones_store_id` on (`store_id`) - FK lookup, listing zones per store

**Notes:** `countries_json` is an array of ISO 3166-1 alpha-2 codes. `regions_json` is an array of region/state codes.

---

### shipping_rates

Shipping rate options within a zone.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| zone_id | INTEGER | No | - | FK -> shipping_zones(id) ON DELETE CASCADE | Parent zone |
| name | TEXT | No | - | - | Rate display name |
| type | TEXT | No | 'flat' | CHECK IN ('flat', 'weight', 'price', 'carrier') | Calculation method |
| config_json | TEXT | No | '{}' | - | Type-dependent configuration (see notes) |
| is_active | INTEGER | No | 1 | - | Boolean: active flag |

**Indexes:**

- `idx_shipping_rates_zone_id` on (`zone_id`) - FK lookup, listing rates per zone
- `idx_shipping_rates_zone_active` on (`zone_id`, `is_active`) - Finding active rates per zone

**Notes:** `config_json` structure depends on `type`:
- Flat: `{"amount": 799}`
- Weight: `{"ranges": [{"min_g": 0, "max_g": 500, "amount": 499}]}`
- Price: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 799}]}`
- Carrier: `{"carrier": "ups", "service": "ground"}`

---

### tax_settings

Per-store tax configuration.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PRIMARY KEY, FK -> stores(id) ON DELETE CASCADE | One-to-one with stores |
| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
| provider | TEXT | No | 'none' | CHECK IN ('stripe_tax', 'none') | External tax provider |
| prices_include_tax | INTEGER | No | 0 | - | Boolean: tax-inclusive pricing |
| config_json | TEXT | No | '{}' | - | Provider-specific config |

---

### discounts

Discount codes and automatic discounts.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| type | TEXT | No | 'code' | CHECK IN ('code', 'automatic') | Activation method |
| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |
| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |
| value_amount | INTEGER | No | 0 | - | See notes for interpretation |
| starts_at | TEXT | No | - | - | ISO-8601 start timestamp |
| ends_at | TEXT | Yes | NULL | - | ISO-8601 end timestamp; null = no expiry |
| usage_limit | INTEGER | Yes | NULL | - | Max redemptions; null = unlimited |
| usage_count | INTEGER | No | 0 | - | Current redemption count |
| rules_json | TEXT | No | '{}' | - | Eligibility rules (min purchase, products, etc.) |
| status | TEXT | No | 'active' | CHECK IN ('draft', 'active', 'expired', 'disabled') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_discounts_store_code` UNIQUE on (`store_id`, `code`) - Unique discount code per store
- `idx_discounts_store_id` on (`store_id`) - FK lookup
- `idx_discounts_store_status` on (`store_id`, `status`) - Filtering discounts by status
- `idx_discounts_store_type` on (`store_id`, `type`) - Filtering by code vs automatic

**Notes:** `value_amount` interpretation depends on `value_type`. For `percent`, it is a whole number (e.g. 15 = 15%). For `fixed`, it is in minor units (cents). For `free_shipping`, it is ignored. `rules_json` may contain minimum purchase amounts, applicable product/collection IDs, customer eligibility, etc.

---

## Epic 6 - Payments, Orders, Fulfillment, and Customers

### customers

Storefront customer accounts, 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 |
| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |
| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |
| name | TEXT | Yes | NULL | - | Display name |
| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_customers_store_email` UNIQUE on (`store_id`, `email`) - Unique email per store
- `idx_customers_store_id` on (`store_id`) - FK lookup, listing customers per store

**Notes:** `password_hash` is nullable to allow guest checkout customers who may later claim their account. Email is unique per store, not globally.

---

### customer_addresses

Saved addresses for a customer.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| customer_id | INTEGER | No | - | FK -> customers(id) ON DELETE CASCADE | Owning customer |
| label | TEXT | Yes | NULL | - | User label, e.g. "Home", "Work" |
| address_json | TEXT | No | '{}' | - | JSON address object (see notes) |
| is_default | INTEGER | No | 0 | - | Boolean: default address flag |

**Indexes:**

- `idx_customer_addresses_customer_id` on (`customer_id`) - FK lookup, listing addresses per customer
- `idx_customer_addresses_default` on (`customer_id`, `is_default`) - Quick lookup of default address

**Notes:** `address_json` structure: `{"first_name": "", "last_name": "", "company": "", "address1": "", "address2": "", "city": "", "province": "", "province_code": "", "country": "", "country_code": "", "zip": "", "phone": ""}`.

---

### orders

Placed orders with financial and fulfillment tracking.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |
| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |
| payment_method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used for this order |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'paid', 'fulfilled', 'cancelled', 'refunded') | Overall order status |
| financial_status | TEXT | No | 'pending' | CHECK IN ('pending', 'authorized', 'paid', 'partially_refunded', 'refunded', 'voided') | Payment status |
| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| subtotal_amount | INTEGER | No | 0 | - | Sum of line items in minor units |
| discount_amount | INTEGER | No | 0 | - | Total discount in minor units |
| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |
| tax_amount | INTEGER | No | 0 | - | Total tax in minor units |
| total_amount | INTEGER | No | 0 | - | Grand total in minor units |
| email | TEXT | Yes | NULL | - | Contact email at time of order |
| billing_address_json | TEXT | Yes | NULL | - | JSON address snapshot |
| shipping_address_json | TEXT | Yes | NULL | - | JSON address snapshot |
| placed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of placement |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_orders_store_order_number` UNIQUE on (`store_id`, `order_number`) - Unique order number per store
- `idx_orders_store_id` on (`store_id`) - FK lookup
- `idx_orders_customer_id` on (`customer_id`) - Listing orders per customer
- `idx_orders_store_status` on (`store_id`, `status`) - Filtering by overall status
- `idx_orders_store_financial` on (`store_id`, `financial_status`) - Filtering by payment status
- `idx_orders_store_fulfillment` on (`store_id`, `fulfillment_status`) - Filtering by shipping status
- `idx_orders_placed_at` on (`store_id`, `placed_at`) - Date-range order queries

---

### order_lines

Individual items within an order. Contains snapshots of product data at time of purchase.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| product_id | INTEGER | Yes | NULL | FK -> products(id) ON DELETE SET NULL | Nullable: survives product deletion |
| variant_id | INTEGER | Yes | NULL | FK -> product_variants(id) ON DELETE SET NULL | Nullable: survives variant deletion |
| title_snapshot | TEXT | No | - | - | Product title at time of purchase |
| sku_snapshot | TEXT | Yes | NULL | - | SKU at time of purchase |
| quantity | INTEGER | No | 1 | - | Number of units |
| unit_price_amount | INTEGER | No | 0 | - | Price per unit in minor units |
| total_amount | INTEGER | No | 0 | - | Line total in minor units |
| tax_lines_json | TEXT | No | '[]' | - | JSON array of tax line items |
| discount_allocations_json | TEXT | No | '[]' | - | JSON array of discount allocations |

**Indexes:**

- `idx_order_lines_order_id` on (`order_id`) - FK lookup, listing lines per order
- `idx_order_lines_product_id` on (`product_id`) - Finding order lines by product
- `idx_order_lines_variant_id` on (`variant_id`) - Finding order lines by variant

**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.

---

### payments

Payment attempts and captures for an order.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| provider | TEXT | No | 'mock' | CHECK IN ('mock') | Payment provider (mock PSP only) |
| method | TEXT | No | - | CHECK IN ('credit_card', 'paypal', 'bank_transfer') | Payment method used |
| provider_payment_id | TEXT | Yes | NULL | - | Mock payment reference ID |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'captured', 'failed', 'refunded') | |
| amount | INTEGER | No | 0 | - | Payment amount in minor units |
| currency | TEXT | No | 'USD' | - | ISO 4217 currency code |
| raw_json_encrypted | TEXT | Yes | NULL | - | Encrypted provider response (PCI compliance) |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_payments_order_id` on (`order_id`) - FK lookup, listing payments per order
- `idx_payments_provider_id` on (`provider`, `provider_payment_id`) - Payment reconciliation by provider ID
- `idx_payments_method` on (`method`) - Filtering payments by method
- `idx_payments_status` on (`status`) - Filtering payments by status

**Notes:** `raw_json_encrypted` stores the mock payment provider response, encrypted at the application layer. `amount` is in minor units (cents). The `provider` is always `mock` since the platform uses a mock PSP (no real payment gateway integration). The `method` column records which payment method the customer selected (credit_card, paypal, or bank_transfer).

---

### refunds

Refund records linked to an order and payment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| payment_id | INTEGER | No | - | FK -> payments(id) ON DELETE CASCADE | Source payment |
| amount | INTEGER | No | 0 | - | Refund amount in minor units |
| reason | TEXT | Yes | NULL | - | Refund reason |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'processed', 'failed') | |
| provider_refund_id | TEXT | Yes | NULL | - | External refund ID |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |


exec

Input:
const r = await tools.exec_command({cmd:"sed -n '861,1290p' specs/01-DATABASE-SCHEMA.md","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":40000});
text(r.output);


Script completed
Wall time 0.1 seconds
Output:

**Indexes:**

- `idx_refunds_order_id` on (`order_id`) - FK lookup, listing refunds per order
- `idx_refunds_payment_id` on (`payment_id`) - Finding refunds per payment
- `idx_refunds_status` on (`status`) - Filtering refunds by status

---

### fulfillments

Shipment tracking for order fulfillment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| order_id | INTEGER | No | - | FK -> orders(id) ON DELETE CASCADE | Parent order |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'shipped', 'delivered') | |
| tracking_company | TEXT | Yes | NULL | - | Carrier name |
| tracking_number | TEXT | Yes | NULL | - | Tracking number |
| tracking_url | TEXT | Yes | NULL | - | Tracking page URL |
| shipped_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_fulfillments_order_id` on (`order_id`) - FK lookup, listing fulfillments per order
- `idx_fulfillments_status` on (`status`) - Filtering fulfillments by status
- `idx_fulfillments_tracking` on (`tracking_company`, `tracking_number`) - Tracking number lookup

---

### fulfillment_lines

Links specific order lines and quantities to a fulfillment.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| fulfillment_id | INTEGER | No | - | FK -> fulfillments(id) ON DELETE CASCADE | Parent fulfillment |
| order_line_id | INTEGER | No | - | FK -> order_lines(id) ON DELETE CASCADE | Source order line |
| quantity | INTEGER | No | 1 | - | Units fulfilled from this line |

**Indexes:**

- `idx_fulfillment_lines_fulfillment_id` on (`fulfillment_id`) - FK lookup, listing lines per fulfillment
- `idx_fulfillment_lines_fulfillment_order_line` UNIQUE on (`fulfillment_id`, `order_line_id`) - Prevents duplicate line entries per fulfillment

**Notes:** An order with multiple line items may be partially fulfilled. This table tracks how many units of each line item are included in each shipment.

---

## Epic 7 - Analytics

### analytics_events

Raw event stream for storefront analytics.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| type | TEXT | No | - | - | Event type (see notes) |
| session_id | TEXT | Yes | NULL | - | Browser session identifier |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for anonymous visitors |
| properties_json | TEXT | No | '{}' | - | Event-specific data as JSON |
| client_event_id | TEXT | Yes | NULL | UNIQUE per (store_id, client_event_id) | Client-provided idempotency key for deduplication |
| occurred_at | TEXT | Yes | NULL | - | ISO-8601 timestamp of when the event actually occurred on the client |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_analytics_events_store_id` on (`store_id`) - FK lookup
- `idx_analytics_events_store_type` on (`store_id`, `type`) - Filtering events by type per store
- `idx_analytics_events_store_created` on (`store_id`, `created_at`) - Time-range event queries
- `idx_analytics_events_session` on (`session_id`) - Session-based event grouping
- `idx_analytics_events_customer` on (`customer_id`) - Customer activity history
- `idx_analytics_events_client_event` UNIQUE on (`store_id`, `client_event_id`) - Client-side event deduplication

**Notes:** Event types include: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`. This table will grow fast - consider periodic archiving or partitioning by date for production workloads beyond SQLite.

---

### analytics_daily

Pre-aggregated daily metrics per store. Composite primary key.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | PK (composite), FK -> stores(id) ON DELETE CASCADE | |
| date | TEXT | No | - | PK (composite) | ISO-8601 date (YYYY-MM-DD) |
| orders_count | INTEGER | No | 0 | - | Number of orders placed |
| revenue_amount | INTEGER | No | 0 | - | Total revenue in minor units |
| aov_amount | INTEGER | No | 0 | - | Average order value in minor units |
| visits_count | INTEGER | No | 0 | - | Unique sessions |
| add_to_cart_count | INTEGER | No | 0 | - | Add-to-cart events |
| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |
| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |

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

**Indexes:**

- `idx_analytics_daily_store_date` on (`store_id`, `date`) - Time-range metric queries (covered by PK but explicit for clarity)

**Notes:** `date` is an ISO-8601 date string (YYYY-MM-DD). `revenue_amount` and `aov_amount` (average order value) are in minor units (cents). This table is populated by a scheduled aggregation job.

---

## Epic 8 - Apps and Webhooks

### apps

Third-party or first-party application registry.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| name | TEXT | No | - | - | App display name |
| status | TEXT | No | 'active' | CHECK IN ('active', 'disabled') | |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_apps_status` on (`status`) - Filtering apps by status

---

### app_installations

Installation of an app on a specific store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | |
| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | |
| scopes_json | TEXT | No | '[]' | - | JSON array of granted permission scopes |
| status | TEXT | No | 'active' | CHECK IN ('active', 'suspended', 'uninstalled') | |
| installed_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

- `idx_app_installations_store_app` UNIQUE on (`store_id`, `app_id`) - One installation per app per store
- `idx_app_installations_store_id` on (`store_id`) - Listing installations per store
- `idx_app_installations_app_id` on (`app_id`) - Finding all stores with an app installed

---

### oauth_clients

OAuth2 client credentials for apps.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| app_id | INTEGER | No | - | FK -> apps(id) ON DELETE CASCADE | Parent app |
| client_id | TEXT | No | - | UNIQUE per table | OAuth2 client identifier |
| client_secret_encrypted | TEXT | No | - | - | Encrypted client secret |
| redirect_uris_json | TEXT | No | '[]' | - | JSON array of allowed redirect URIs |

**Indexes:**

- `idx_oauth_clients_client_id` UNIQUE on (`client_id`) - OAuth2 client lookup
- `idx_oauth_clients_app_id` on (`app_id`) - FK lookup, listing clients per app

---

### oauth_tokens

Issued access/refresh tokens for app installations.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| installation_id | INTEGER | No | - | FK -> app_installations(id) ON DELETE CASCADE | Parent installation |
| access_token_hash | TEXT | No | - | UNIQUE per table | Hashed access token |
| refresh_token_hash | TEXT | Yes | NULL | - | Hashed refresh token |
| expires_at | TEXT | No | - | - | ISO-8601 expiration timestamp |

**Indexes:**

- `idx_oauth_tokens_installation_id` on (`installation_id`) - FK lookup, listing tokens per installation
- `idx_oauth_tokens_access_hash` UNIQUE on (`access_token_hash`) - Token validation lookup
- `idx_oauth_tokens_expires_at` on (`expires_at`) - Cleanup of expired tokens

---

### webhook_subscriptions

Registered webhook endpoints for event delivery.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| app_installation_id | INTEGER | Yes | NULL | FK -> app_installations(id) ON DELETE CASCADE | Nullable for store-level hooks |
| event_type | TEXT | No | - | - | Event to subscribe to |
| target_url | TEXT | No | - | - | Delivery endpoint URL |
| signing_secret_encrypted | TEXT | No | - | - | Encrypted HMAC signing secret |
| status | TEXT | No | 'active' | CHECK IN ('active', 'paused', 'disabled') | |

**Indexes:**

- `idx_webhook_subscriptions_store_id` on (`store_id`) - FK lookup
- `idx_webhook_subscriptions_store_event` on (`store_id`, `event_type`) - Finding subscriptions for a given event
- `idx_webhook_subscriptions_installation` on (`app_installation_id`) - Listing subscriptions per app installation

---

### webhook_deliveries

Delivery log for webhook attempts.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| subscription_id | INTEGER | No | - | FK -> webhook_subscriptions(id) ON DELETE CASCADE | Parent subscription |
| event_id | TEXT | No | - | - | Unique event identifier |
| attempt_count | INTEGER | No | 1 | - | Number of delivery attempts |
| status | TEXT | No | 'pending' | CHECK IN ('pending', 'success', 'failed') | |
| last_attempt_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| response_code | INTEGER | Yes | NULL | - | HTTP response code |
| response_body_snippet | TEXT | Yes | NULL | - | Truncated response body for debugging |

**Indexes:**

- `idx_webhook_deliveries_subscription_id` on (`subscription_id`) - FK lookup, listing deliveries per subscription
- `idx_webhook_deliveries_event_id` on (`event_id`) - Deduplication and lookup by event
- `idx_webhook_deliveries_status` on (`status`) - Finding pending/failed deliveries for retry
- `idx_webhook_deliveries_last_attempt` on (`last_attempt_at`) - Time-based retry scheduling

---

## Laravel Framework Tables

The following tables are managed by Laravel's built-in migrations and should not be modified.

### sessions

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | TEXT | No | - | PRIMARY KEY | Session ID |
| user_id | INTEGER | Yes | NULL | - | Associated user |
| ip_address | TEXT | Yes | NULL | - | Client IP |
| user_agent | TEXT | Yes | NULL | - | Client user agent |
| payload | TEXT | No | - | - | Serialized session data |
| last_activity | INTEGER | No | - | - | Unix timestamp |

### cache

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| key | TEXT | No | - | PRIMARY KEY | Cache key |
| value | TEXT | No | - | - | Serialized cache value |
| expiration | INTEGER | No | - | - | Unix timestamp |

### cache_locks

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| key | TEXT | No | - | PRIMARY KEY | Lock key |
| owner | TEXT | No | - | - | Lock owner identifier |
| expiration | INTEGER | No | - | - | Unix timestamp |

### jobs

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| queue | TEXT | No | - | - | Queue name |
| payload | TEXT | No | - | - | Serialized job payload |
| attempts | INTEGER | No | - | - | Number of attempts |
| reserved_at | INTEGER | Yes | NULL | - | Unix timestamp |
| available_at | INTEGER | No | - | - | Unix timestamp |
| created_at | INTEGER | No | - | - | Unix timestamp |

### job_batches

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | TEXT | No | - | PRIMARY KEY | Batch UUID |
| name | TEXT | No | - | - | Batch name |
| total_jobs | INTEGER | No | - | - | Total jobs in batch |
| pending_jobs | INTEGER | No | - | - | Pending count |
| failed_jobs | INTEGER | No | - | - | Failed count |
| failed_job_ids | TEXT | No | - | - | Serialized list of failed IDs |
| options | TEXT | Yes | NULL | - | Serialized batch options |
| cancelled_at | INTEGER | Yes | NULL | - | Unix timestamp |
| created_at | INTEGER | No | - | - | Unix timestamp |
| finished_at | INTEGER | Yes | NULL | - | Unix timestamp |

### failed_jobs

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| uuid | TEXT | No | - | UNIQUE | Job UUID |
| connection | TEXT | No | - | - | Queue connection name |
| queue | TEXT | No | - | - | Queue name |
| payload | TEXT | No | - | - | Serialized job payload |
| exception | TEXT | No | - | - | Exception trace |
| failed_at | TEXT | No | CURRENT_TIMESTAMP | - | ISO-8601 timestamp |

### personal_access_tokens

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| tokenable_type | TEXT | No | - | - | Polymorphic type |
| tokenable_id | INTEGER | No | - | - | Polymorphic ID |
| name | TEXT | No | - | - | Token name |
| token | TEXT | No | - | UNIQUE | SHA-256 hash of the token |
| abilities | TEXT | Yes | NULL | - | JSON array of abilities |
| last_used_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| expires_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

### password_reset_tokens

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| email | TEXT | No | - | PRIMARY KEY | Admin user email |
| token | TEXT | No | - | - | Hashed reset token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

---

### customer_password_reset_tokens

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| email | TEXT | No | - | - | Customer email |
| token | TEXT | No | - | - | Hashed reset token |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

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

---

## Entity Relationship Diagrams

### Epic 1 - Foundation and Tenancy

```mermaid
erDiagram
    organizations ||--o{ stores : "owns"
    stores ||--o{ store_domains : "has"
    stores ||--o{ store_users : "links"
    users ||--o{ store_users : "links"
    stores ||--|| store_settings : "has"

    organizations {
        INTEGER id PK
        TEXT name
        TEXT billing_email
    }
    stores {
        INTEGER id PK
        INTEGER organization_id FK
        TEXT name
        TEXT handle
        TEXT status
    }
    store_domains {
        INTEGER id PK
        INTEGER store_id FK
        TEXT hostname
        TEXT type
        INTEGER is_primary
    }
    users {
        INTEGER id PK
        TEXT email
        TEXT password_hash
        TEXT name
        TEXT status
    }
    store_users {
        INTEGER store_id PK
        INTEGER user_id PK
        TEXT role
    }
    store_settings {
        INTEGER store_id PK
        TEXT settings_json
    }
```

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

```mermaid
erDiagram
    stores ||--o{ products : "has"
    stores ||--o{ collections : "has"
    products ||--o{ product_options : "defines"
    product_options ||--o{ product_option_values : "contains"
    products ||--o{ product_variants : "has"
    product_variants }o--o{ product_option_values : "variant_option_values"
    product_variants ||--|| inventory_items : "tracked by"
    stores ||--o{ inventory_items : "has"
    collections }o--o{ products : "collection_products"
    products ||--o{ product_media : "has"

    products {
        INTEGER id PK
        INTEGER store_id FK
        TEXT title
        TEXT handle
        TEXT status
    }
    product_options {
        INTEGER id PK
        INTEGER product_id FK
        TEXT name
        INTEGER position
    }
    product_option_values {
        INTEGER id PK
        INTEGER product_option_id FK
        TEXT value
        INTEGER position
    }
    product_variants {
        INTEGER id PK
        INTEGER product_id FK
        TEXT sku
        INTEGER price_amount
        TEXT status


Input:
const r = await tools.exec_command({cmd:"sed -n '1291,1760p' specs/01-DATABASE-SCHEMA.md","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":40000});
text(r.output);


Script completed
Wall time 0.1 seconds
Output:

    }
    inventory_items {
        INTEGER id PK
        INTEGER store_id FK
        INTEGER variant_id FK
        INTEGER quantity_on_hand
        INTEGER quantity_reserved
        TEXT policy
    }
    collections {
        INTEGER id PK
        INTEGER store_id FK
        TEXT title
        TEXT handle
        TEXT type
        TEXT status
    }
    product_media {
        INTEGER id PK
        INTEGER product_id FK
        TEXT type
        TEXT storage_key
        INTEGER position
        TEXT status
    }
```

### Epic 3 - Themes, Storefront, and Localization

```mermaid
erDiagram
    stores ||--o{ themes : "has"
    themes ||--o{ theme_files : "contains"
    themes ||--|| theme_settings : "configured by"
    stores ||--o{ pages : "has"
    stores ||--o{ navigation_menus : "has"
    navigation_menus ||--o{ navigation_items : "contains"

    themes {
        INTEGER id PK
        INTEGER store_id FK
        TEXT name
        TEXT status
    }
    theme_files {
        INTEGER id PK
        INTEGER theme_id FK
        TEXT path
        TEXT storage_key
    }
    theme_settings {
        INTEGER theme_id PK
        TEXT settings_json
    }
    pages {
        INTEGER id PK
        INTEGER store_id FK
        TEXT title
        TEXT handle
        TEXT status
    }
    navigation_menus {
        INTEGER id PK
        INTEGER store_id FK
        TEXT handle
        TEXT title
    }
    navigation_items {
        INTEGER id PK
        INTEGER menu_id FK
        TEXT type
        TEXT label
        INTEGER position
    }
```

### Epics 5-6 - Cart, Checkout, Orders, and Payments

```mermaid
erDiagram
    stores ||--o{ customers : "has"
    customers ||--o{ customer_addresses : "has"
    stores ||--o{ carts : "has"
    customers ||--o{ carts : "owns"
    carts ||--o{ cart_lines : "contains"
    product_variants ||--o{ cart_lines : "referenced by"
    stores ||--o{ checkouts : "has"
    carts ||--o{ checkouts : "converted to"
    stores ||--o{ orders : "has"
    customers ||--o{ orders : "placed"
    orders ||--o{ order_lines : "contains"
    orders ||--o{ payments : "paid via"
    orders ||--o{ refunds : "refunded via"
    payments ||--o{ refunds : "source of"
    orders ||--o{ fulfillments : "shipped via"
    fulfillments ||--o{ fulfillment_lines : "contains"
    order_lines ||--o{ fulfillment_lines : "fulfilled by"

    customers {
        INTEGER id PK
        INTEGER store_id FK
        TEXT email
        TEXT name
    }
    carts {
        INTEGER id PK
        INTEGER store_id FK
        INTEGER customer_id FK
        TEXT status
    }
    orders {
        INTEGER id PK
        INTEGER store_id FK
        INTEGER customer_id FK
        TEXT order_number
        TEXT payment_method
        TEXT status
        TEXT financial_status
        TEXT fulfillment_status
        INTEGER total_amount
    }
    payments {
        INTEGER id PK
        INTEGER order_id FK
        TEXT provider
        TEXT method
        TEXT status
        INTEGER amount
    }
    fulfillments {
        INTEGER id PK
        INTEGER order_id FK
        TEXT status
        TEXT tracking_number
    }
```

### Epics 7-8 - Analytics, Apps, and Webhooks

```mermaid
erDiagram
    stores ||--o{ analytics_events : "records"
    stores ||--o{ analytics_daily : "aggregates"
    customers ||--o{ analytics_events : "generates"
    apps ||--o{ app_installations : "installed via"
    stores ||--o{ app_installations : "has"
    apps ||--o{ oauth_clients : "authenticates via"
    app_installations ||--o{ oauth_tokens : "issues"
    stores ||--o{ webhook_subscriptions : "registers"
    app_installations ||--o{ webhook_subscriptions : "owns"
    webhook_subscriptions ||--o{ webhook_deliveries : "delivers"

    analytics_events {
        INTEGER id PK
        INTEGER store_id FK
        TEXT type
        TEXT session_id
    }
    analytics_daily {
        INTEGER store_id PK
        TEXT date PK
        INTEGER orders_count
        INTEGER revenue_amount
    }
    apps {
        INTEGER id PK
        TEXT name
        TEXT status
    }
    app_installations {
        INTEGER id PK
        INTEGER store_id FK
        INTEGER app_id FK
        TEXT status
    }
    webhook_subscriptions {
        INTEGER id PK
        INTEGER store_id FK
        TEXT event_type
        TEXT target_url
    }
    webhook_deliveries {
        INTEGER id PK
        INTEGER subscription_id FK
        TEXT event_id
        TEXT status
    }
```

---

## SQLite-Specific Configuration Requirements

The application requires specific SQLite PRAGMA settings configured in the Laravel database configuration file (`config/database.php`). These settings must be applied for correct and performant operation.

### Required PRAGMA Settings

| Pragma | Value | Purpose |
|---|---|---|
| `foreign_keys` | ON | Enforce referential integrity on every connection. Without this, foreign key constraints are silently ignored by SQLite. |
| `journal_mode` | WAL | Enable Write-Ahead Logging to allow concurrent reads during writes. Critical for web application performance. |
| `busy_timeout` | 5000 | Wait up to 5 seconds for a write lock before throwing an error. Prevents immediate failures under concurrent load. |
| `synchronous` | NORMAL | Balances durability and performance. Safe when used with WAL mode; avoids fsync on every commit. |
| `cache_size` | -20000 | Use up to 20 MB of page cache. Set in AppServiceProvider if needed. Negative value means kilobytes. |

### Laravel Configuration Requirements

- The SQLite driver connection must set `foreign_key_constraints` to `true` so that every new database connection enables foreign key enforcement.
- The `journal_mode` must be set to `wal` (Write-Ahead Logging) for concurrent read support.
- The `busy_timeout` must be set to `5000` (milliseconds) to handle write contention gracefully.
- The `synchronous` mode must be set to `normal` for balanced durability with WAL mode.
- The `transaction_mode` should be set to `DEFERRED` (the default), meaning transactions acquire locks lazily.
- The `cache_size` pragma (set to `-20000` for 20 MB) should be applied via the AppServiceProvider boot method if needed for performance tuning.

---

## Migration Dependency Graph

Migrations must be created in an order that satisfies foreign key dependencies. The following flowchart shows the required sequence grouped into batches. Tables within the same batch have no inter-dependencies and can appear in any order.

```mermaid
flowchart TD
    subgraph "Batch 1 - No Dependencies"
        organizations
        apps
    end

    subgraph "Batch 2 - Depends on Batch 1"
        stores
        users
    end

    subgraph "Batch 3 - Depends on Batch 2"
        store_domains
        store_users
        store_settings
        customers
        themes
        pages
        navigation_menus
        search_settings
        shipping_zones
        tax_settings
        discounts
        products
        collections
        app_installations
        oauth_clients
    end

    subgraph "Batch 4 - Depends on Batch 3"
        product_options
        product_variants
        product_media
        collection_products
        customer_addresses
        carts
        navigation_items
        theme_files
        theme_settings
        search_queries
        shipping_rates
        oauth_tokens
        webhook_subscriptions
    end

    subgraph "Batch 5 - Depends on Batch 4"
        product_option_values
        inventory_items
        cart_lines
        checkouts
        orders
        analytics_events
        analytics_daily
        webhook_deliveries
    end

    subgraph "Batch 6 - Depends on Batch 5"
        variant_option_values
        order_lines
        payments
        fulfillments
    end

    subgraph "Batch 7 - Depends on Batch 6"
        refunds
        fulfillment_lines
    end

    organizations --> stores
    apps --> app_installations
    apps --> oauth_clients
    stores --> store_domains
    stores --> store_users
    users --> store_users
    stores --> store_settings
    stores --> customers
    stores --> themes
    stores --> pages
    stores --> navigation_menus
    stores --> search_settings
    stores --> shipping_zones
    stores --> tax_settings
    stores --> discounts
    stores --> products
    stores --> collections
    stores --> app_installations
    products --> product_options
    products --> product_variants
    products --> product_media
    collections --> collection_products
    products --> collection_products
    customers --> customer_addresses
    stores --> carts
    customers --> carts
    navigation_menus --> navigation_items
    themes --> theme_files
    themes --> theme_settings
    stores --> search_queries
    shipping_zones --> shipping_rates
    app_installations --> oauth_tokens
    stores --> webhook_subscriptions
    app_installations --> webhook_subscriptions
    product_options --> product_option_values
    stores --> inventory_items
    product_variants --> inventory_items
    carts --> cart_lines
    product_variants --> cart_lines
    stores --> checkouts
    carts --> checkouts
    customers --> checkouts
    stores --> orders
    customers --> orders
    stores --> analytics_events
    customers --> analytics_events
    stores --> analytics_daily
    webhook_subscriptions --> webhook_deliveries
    product_variants --> variant_option_values
    product_option_values --> variant_option_values
    orders --> order_lines
    products --> order_lines
    product_variants --> order_lines
    orders --> payments
    orders --> fulfillments
    orders --> refunds
    payments --> refunds
    fulfillments --> fulfillment_lines
    order_lines --> fulfillment_lines
```

### Batch Details

- **Batch 1** (no dependencies): `organizations`, `apps`
- **Batch 2** (depends on Batch 1): `stores` (references organizations), `users` (standalone, but logically grouped here)
- **Batch 3** (depends on Batch 2): `store_domains`, `store_users`, `store_settings`, `customers`, `themes`, `pages`, `navigation_menus`, `search_settings`, `shipping_zones`, `tax_settings`, `discounts`, `products`, `collections`, `app_installations`, `oauth_clients`
- **Batch 4** (depends on Batch 3): `product_options`, `product_variants`, `product_media`, `collection_products`, `customer_addresses`, `carts`, `navigation_items`, `theme_files`, `theme_settings`, `search_queries`, `shipping_rates`, `oauth_tokens`, `webhook_subscriptions`
- **Batch 5** (depends on Batch 4): `product_option_values`, `inventory_items`, `cart_lines`, `checkouts`, `orders`, `analytics_events`, `analytics_daily`, `webhook_deliveries`
- **Batch 6** (depends on Batch 5): `variant_option_values`, `order_lines`, `payments`, `fulfillments`
- **Batch 7** (depends on Batch 6): `refunds`, `fulfillment_lines`

---

## Index Strategy Rationale

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.

2. **Unique constraints on business keys** - Handles, codes, email+store, and external identifiers (SKUs, barcodes, hostnames, OAuth client IDs) have unique indexes to enforce data integrity at the database level.

3. **Foreign key indexes** - Every foreign key column has an index to avoid full table scans during JOIN and CASCADE operations. SQLite does not auto-create indexes for foreign keys like some other databases.

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.

5. **Date-based indexes for time series** - Tables with temporal queries (orders, analytics, search queries, webhook deliveries) include indexes on date/timestamp columns scoped by store for efficient range scans.

6. **Selective indexing** - Not every column is indexed. Columns queried only via their parent's primary key (e.g., `cart_lines` always accessed through `cart_id`) rely on the foreign key index alone. This keeps write overhead and database size manageable.

---

## Table Count Summary

| Epic | Tables | Count |
|---|---|---|
| 1 - Foundation and Multi-Store Tenancy | organizations, stores, store_domains, users, store_users, store_settings | 6 |
| 2 - Catalog, Collections, Inventory, Media | products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media | 9 |
| 3 - Themes, Storefront, Localization | themes, theme_files, theme_settings, pages, navigation_menus, navigation_items | 6 |
| 4 - Search | search_settings, search_queries | 2 |
| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |
| 6 - Payments, Orders, Fulfillment, Customers | customers, customer_addresses, orders, order_lines, payments, refunds, fulfillments, fulfillment_lines | 8 |
| 7 - Analytics | analytics_events, analytics_daily | 2 |
| 8 - Apps and Webhooks | apps, app_installations, oauth_clients, oauth_tokens, webhook_subscriptions, webhook_deliveries | 6 |
| **Total application tables** | | **46** |
| Laravel framework tables | sessions, cache, cache_locks, jobs, job_batches, failed_jobs, personal_access_tokens, password_reset_tokens, customer_password_reset_tokens | 9 |
| **Grand total** | | **55** |


Input:
const r = await tools.exec_command({cmd:"test -d vendor && echo vendor-present || echo vendor-missing\nphp -v | head -2\nphp artisan --version\nphp -r \"echo extension_loaded('pdo_sqlite') ? 'pdo_sqlite=yes' : 'pdo_sqlite=no'; echo PHP_EOL; echo extension_loaded('sqlite3') ? 'sqlite3=yes' : 'sqlite3=no'; echo PHP_EOL;\"\nphp artisan test --compact","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":30000,"max_output_tokens":30000});
text(r.output);
if (r.session_id) text(JSON.stringify({session_id:r.session_id}));


Script completed
Wall time 2.3 seconds
Output:

vendor-present
PHP 8.4.17 (cli) (built: Jan 16 2026 00:18:43) (NTS clang 15.0.0)
Copyright (c) The PHP Group
Laravel Framework 12.51.0
pdo_sqlite=yes
sqlite3=yes

  ⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯⨯
  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Unit\ExampleTest > that true is true   PortNotFoundException
  Unable to find an available port.

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\AuthenticationTest > login scr…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\AuthenticationTest > users can…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\AuthenticationTest > users can…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\AuthenticationTest > users wit…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\AuthenticationTest > users can…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\EmailVerificationTest > email…   ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\EmailVerificationTest > email…   ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\EmailVerificationTest > email…   ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\EmailVerificationTest > alread…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordConfirmationTest > con…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > reset pass…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > reset pass…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > reset pass…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\PasswordResetTest > password c…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\RegistrationTest > registratio…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\RegistrationTest > new users c…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\TwoFactorChallengeTest > two f…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Auth\TwoFactorChallengeTest > two f…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\DashboardTest > guests are redirect…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\DashboardTest > authenticated users…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\ExampleTest > it returns a successf…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\PasswordUpdateTest > passw…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\PasswordUpdateTest > corre…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\ProfileUpdateTest > profil…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\ProfileUpdateTest > profil…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\ProfileUpdateTest > email…   ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\ProfileUpdateTest > user c…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\ProfileUpdateTest > correc…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\TwoFactorAuthenticationTes…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\TwoFactorAuthenticationTes…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\TwoFactorAuthenticationTes…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  ────────────────────────────────────────────────────────────────────────────
   FAILED  Tests\Feature\Settings\TwoFactorAuthenticationTes…  ErrorException
  socket_create_listen(): unable to bind to given address [1]: Operation not permitted

  Tests:    33 failed (75 assertions)
  Duration: 1.62s

   Pest\Browser\Exceptions\PortNotFoundException

  Unable to find an available port.

  at vendor/pestphp/pest-plugin-browser/src/Support/Port.php:31
     27▕         }
     28▕
     29▕         if ($port === false) {
     30▕             // @codeCoverageIgnoreStart
  ➜  31▕             throw new PortNotFoundException('Unable to find an available port.');
     32▕             // @codeCoverageIgnoreEnd
     33▕         }
     34▕
     35▕         assert(is_int($port));

   INFO  Warning: socket_create_listen(): unable to bind to given address [1]: Operation not permitted in /Users/fabianwesner/Herd/shop/vendor/pestphp/pest-plugin-browser/src/Support/Port.php on line 21.


Input:
const r = await tools.exec_command({cmd:"sed -n '1,240p' tests/Pest.php\nsed -n '1,220p' phpunit.xml\ncomposer show laravel/sanctum --no-interaction 2>/dev/null || true\ncomposer show pestphp/pest-plugin-browser --no-interaction 2>/dev/null || true\nrg -n \"browser\\(|pest-plugin-browser|Port\" tests composer.lock","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":20000});
text(r.output);


Script completed
Wall time 0.5 seconds
Output:

<?php

/*
|--------------------------------------------------------------------------
| Test Case
|--------------------------------------------------------------------------
|
| The closure you provide to your test functions is always bound to a specific PHPUnit test
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
| need to change it using the "pest()" function to bind a different classes or traits.
|
*/

pest()->extend(Tests\TestCase::class)
 // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

/*
|--------------------------------------------------------------------------
| Expectations
|--------------------------------------------------------------------------
|
| When you're writing tests, you often need to check that values meet certain conditions. The
| "expect()" function gives you access to a set of "expectations" methods that you can use
| to assert different things. Of course, you may extend the Expectation API at any time.
|
*/

expect()->extend('toBeOne', function () {
    return $this->toBe(1);
});

/*
|--------------------------------------------------------------------------
| Functions
|--------------------------------------------------------------------------
|
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
| project that you don't want to repeat in every file. Here you can also expose helpers as
| global functions to help you to reduce the number of lines of code in your test files.
|
*/

function something()
{
    // ..
}
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="vendor/autoload.php"
         colors="true"
>
    <testsuites>
        <testsuite name="Unit">
            <directory>tests/Unit</directory>
        </testsuite>
        <testsuite name="Feature">
            <directory>tests/Feature</directory>
        </testsuite>
    </testsuites>
    <source>
        <include>
            <directory>app</directory>
        </include>
    </source>
    <php>
        <env name="APP_ENV" value="testing"/>
        <env name="APP_MAINTENANCE_DRIVER" value="file"/>
        <env name="BCRYPT_ROUNDS" value="4"/>
        <env name="BROADCAST_CONNECTION" value="null"/>
        <env name="CACHE_STORE" value="array"/>
        <env name="DB_CONNECTION" value="sqlite"/>
        <env name="DB_DATABASE" value=":memory:"/>
        <env name="MAIL_MAILER" value="array"/>
        <env name="QUEUE_CONNECTION" value="sync"/>
        <env name="SESSION_DRIVER" value="array"/>
        <env name="PULSE_ENABLED" value="false"/>
        <env name="TELESCOPE_ENABLED" value="false"/>
        <env name="NIGHTWATCH_ENABLED" value="false"/>
    </php>
</phpunit>
name     : laravel/sanctum
descrip. : Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.
keywords : auth, laravel, sanctum
versions : * v4.3.2
released : 2026-04-30, 2 months ago
type     : library
license  : MIT License (MIT) (OSI approved) https://spdx.org/licenses/MIT.html#licenseText
homepage :
source   : [git] https://github.com/laravel/sanctum.git 2a9bccc18e9907808e0018dd15fa643937886b1e
dist     : [zip] https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e 2a9bccc18e9907808e0018dd15fa643937886b1e
path     : /Users/fabianwesner/Herd/shop/vendor/laravel/sanctum
names    : laravel/sanctum

support
issues : https://github.com/laravel/sanctum/issues
source : https://github.com/laravel/sanctum

autoload
psr-4
Laravel\Sanctum\ => src/

requires
ext-json *
illuminate/console ^11.0|^12.0|^13.0
illuminate/contracts ^11.0|^12.0|^13.0
illuminate/database ^11.0|^12.0|^13.0
illuminate/support ^11.0|^12.0|^13.0
php ^8.2
symfony/console ^7.0|^8.0

requires (dev)
mockery/mockery ^1.6
orchestra/testbench ^9.15|^10.8|^11.0
phpstan/phpstan ^1.10
name     : pestphp/pest-plugin-browser
descrip. : Pest plugin to test browser interactions
keywords : browser, framework, pest, php, test, testing, unit
versions : * v4.3.0
released : 2026-02-17, 4 months ago
type     : library
license  : MIT License (MIT) (OSI approved) https://spdx.org/licenses/MIT.html#licenseText
homepage :
source   : [git] https://github.com/pestphp/pest-plugin-browser.git 48bc408033281974952a6b296592cef3b920a2db
dist     : [zip] https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db 48bc408033281974952a6b296592cef3b920a2db
path     : /Users/fabianwesner/Herd/shop/vendor/pestphp/pest-plugin-browser
names    : pestphp/pest-plugin-browser

support
source : https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0

autoload
files
psr-4
Pest\Browser\ => src/

requires
amphp/amp ^3.1.1
amphp/http-server ^3.4.4
amphp/websocket-client ^2.0.2
ext-sockets *
pestphp/pest ^4.3.2
pestphp/pest-plugin ^4.0.0
php ^8.3
symfony/process ^7.4.5|^8.0.5

requires (dev)
ext-pcntl *
ext-posix *
livewire/livewire ^3.7.10
nunomaduro/collision ^8.9.0
orchestra/testbench ^10.9.0
pestphp/pest-dev-tools ^4.1.0
pestphp/pest-plugin-laravel ^4.0
pestphp/pest-plugin-type-coverage ^4.0.3
composer.lock:6394:            "description": "Portable ASCII library - performance optimized (ascii) string functions for php.",
composer.lock:7553:                "pestphp/pest-plugin-browser": "^4.2.1",


Output: timeout_ms must be at least 10000


Input:
const r = await tools.exec_command({cmd:"sed -n '1,460p' specs/05-BUSINESS-LOGIC.md","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":40000});
text(r.output);


Script completed
Wall time 0.1 seconds
Output:

# 05 - Business Logic Specification

> E-commerce platform - All domain logic, state machines, calculations, and rules for Laravel 12

---

## Conventions

| Convention | Detail |
|---|---|
| Monetary amounts | **INTEGER in minor units** (cents). All arithmetic uses integers - never floating point. |
| Percentage storage | Basis points where noted (e.g., 1900 = 19.00%) or whole-number percentage in `discounts.value_amount` (e.g., 15 = 15%) |
| Rounding | Round each line independently, then sum (prevents rounding drift) |
| Database | SQLite - single-writer, WAL mode, no advisory locks needed |
| Time | All timestamps in ISO-8601 UTC via Laravel `created_at` / `updated_at` |
| Currency | Single currency per store (`stores.default_currency`). No multi-currency conversion. |

---

## 1. Multi-Tenancy and Store Isolation

### 1.1 Tenant Resolution Middleware

**Class:** `App\Http\Middleware\ResolveStore`

**Register** in `bootstrap/app.php` under both the `storefront` and `admin` middleware groups.

#### Storefront Route Resolution (from request hostname)

1. Extract hostname from the incoming HTTP request.
2. Look up `store_domains` table where `hostname` matches.
3. Cache the hostname-to-store_id mapping using file cache with a 5-minute TTL. Cache key pattern: `store_domain:{hostname}`.
4. Retrieve the full Store model and bind it as a singleton in the application container under the key `current_store`.
5. If hostname is not found in `store_domains`: return HTTP 404.
6. If the resolved store has `status = 'suspended'`: return HTTP 503 maintenance page.

#### Admin Route Resolution (from session)

1. Read the selected store ID from the authenticated user's session (key: `current_store_id`).
2. Verify the authenticated user has a `store_users` record for that store.
3. If the store is suspended: return HTTP 403 Forbidden. Admin users can still view settings but cannot perform storefront-affecting mutations.

```mermaid
flowchart TD
    A[Incoming Request] --> B{Route type?}
    B -->|Storefront| C[Extract hostname]
    C --> D{Hostname in cache?}
    D -->|Yes| E[Load store from cache]
    D -->|No| F[Query store_domains table]
    F --> G{Found?}
    G -->|No| H[Return 404]
    G -->|Yes| I[Cache hostname->store_id, 5 min TTL]
    I --> E
    E --> J{Store suspended?}
    J -->|Yes| K[Return 503 maintenance]
    J -->|No| L[Bind store as singleton, continue]

    B -->|Admin| M[Read store_id from session]
    M --> N{User has store_users record?}
    N -->|No| O[Return 403]
    N -->|Yes| P{Store suspended?}
    P -->|Yes| Q[Return 403 for mutations]
    P -->|No| L
```

### 1.2 Store Scoping

**Trait:** `App\Models\Concerns\BelongsToStore`
**Global Scope:** `App\Models\Scopes\StoreScope`

The trait applies a global query scope to every model that uses it. The scope adds a `WHERE store_id = <current_store_id>` clause to all queries automatically.

**Behavior:**

- On the model's `creating` event, automatically set `store_id` from the current store context (defense-in-depth).
- All SELECT, UPDATE, DELETE queries on tenant-scoped models include the store filter.
- In platform-admin contexts where cross-store access is needed, the scope can be temporarily removed.

**Models that must use this trait:**
products, collections, customers, orders, carts, checkouts, discounts, shipping_zones, themes, pages, navigation_menus, analytics_events, analytics_daily, webhook_subscriptions, inventory_items, search_queries.

### 1.3 Role-Based Access Control

**Enum:** `App\Enums\StoreUserRole` - backed by string values: `owner`, `admin`, `staff`, `support`

**Permission Matrix:**

| Permission | Owner | Admin | Staff | Support |
|---|---|---|---|---|
| Manage store settings | Yes | Yes | No | No |
| Manage staff | Yes | Yes | No | No |
| Manage products | Yes | Yes | Yes | No |
| Manage orders | Yes | Yes | Yes | Yes (read-only) |
| Manage discounts | Yes | Yes | Yes | No |
| Manage themes | Yes | Yes | No | No |
| Manage apps | Yes | Yes | No | No |
| View analytics | Yes | Yes | Yes | No |
| Manage shipping/tax | Yes | Yes | No | No |
| Process refunds | Yes | Yes | No | No |
| Create fulfillments | Yes | Yes | Yes | No |

**Implementation requirements:**

- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
- 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.

### 2.4 Handle/Slug Generation

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

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

---

## 3. Inventory Management

### 3.1 Tracking Model

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

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

### 3.2 Inventory Policy

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

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

### 3.3 Inventory Operations

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

All operations must execute within a database transaction.

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

**Operation definitions:**

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

### 3.4 Concurrency

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

---

## 4. Cart Service

### 4.1 Cart Lifecycle

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

```mermaid
stateDiagram-v2
    [*] --> active : Cart created
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

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

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

**Cart merge on login:**

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

### 4.2 Cart Operations

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

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

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

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

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

### 4.3 Cart Version (Optimistic Concurrency)

Every cart mutation increments `cart_version` by 1.

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

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

### 4.4 Line Amount Calculation

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

All values are integers in minor currency units.

### 4.5 Cart Expiration

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

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

---

## 5. Pricing Engine

### 5.1 Calculation Order (deterministic pipeline)

**Service:** `App\Services\PricingEngine`

The pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.

```mermaid
flowchart TD
    A["Step 1: Line Subtotals
    FOR EACH line: subtotal = unit_price * quantity"] --> B["Step 2: Cart Subtotal
    SUM of all line subtotals"]
    B --> C["Step 3: Discount
    Apply discount rules (Section 7)
    Allocate proportionally across qualifying lines"]
    C --> D["Step 4: Discounted Subtotal
    subtotal - total_discount"]
    D --> E["Step 5: Shipping
    Based on selected method and rate rules (Section 9)"]
    E --> F["Step 6: Tax
    Calculated on discounted line amounts
    + shipping if taxable (Section 8)"]
    F --> G["Step 7: Total
    discounted_subtotal + shipping + tax_total"]
```

### 5.2 Output Structure

**Value Object:** `App\ValueObjects\PricingResult`

| Field | Type | Description |
|---|---|---|
| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
| `discount` | integer (cents) | Total discount amount |
| `shipping` | integer (cents) | Shipping cost |
| `taxLines` | array of TaxLine | Individual tax line items |
| `taxTotal` | integer (cents) | Sum of all tax line amounts |
| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
| `currency` | string | ISO 4217 currency code |

**Value Object:** `App\ValueObjects\TaxLine`

| Field | Type | Description |
|---|---|---|
| `name` | string | Tax label (e.g., "State Tax") |
| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |
| `amount` | integer (cents) | Calculated tax amount |

### 5.3 Rounding Strategy

- Each line's discount allocation, tax, and total are rounded independently.
- Totals are computed by summing already-rounded line amounts.
- This prevents accumulation of fractional cents across lines.
- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).

### 5.4 Determinism and Snapshotting

- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
- The snapshot structure mirrors the PricingResult value object.

---

## 6. Checkout State Machine

### 6.1 States

**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`

```mermaid
stateDiagram-v2
    [*] --> started : Checkout created from cart
    started --> addressed : Address provided
    addressed --> shipping_selected : Shipping method chosen
    shipping_selected --> payment_selected : Payment method selected
    payment_selected --> completed : Payment confirmed
    payment_selected --> expired : Timeout (24h)

    started --> expired : Timeout (24h)
    addressed --> expired : Timeout (24h)
    shipping_selected --> expired : Timeout (24h)

    note right of addressed : Triggers pricing recalculation
    note right of shipping_selected : Triggers pricing recalculation
    note right of payment_selected : Inventory reserved
    note right of completed : Order created, inventory committed
    note right of expired : Reserved inventory released
```

### 6.2 Transitions and Validation

**Service:** `App\Services\CheckoutService`

#### Transition: started -> addressed

**Method:** `setAddress(checkout, addressData)`

**Required data:**
- `email` - valid email address
- `shipping_address` with required fields:

| Field | Required | Type | Notes |
|---|---|---|---|
| `first_name` | Yes | string | |
| `last_name` | Yes | string | |
| `address1` | Yes | string | |
| `address2` | No | string | |
| `company` | No | string | |
| `city` | Yes | string | |
| `province` | No | string | |
| `province_code` | No | string | Used for zone matching |
| `country` | Yes | string | 2-letter ISO 3166-1 code |
| `postal_code` | Yes | string | |


Output: {"message":"Wait timed out.","timed_out":true}


Input:
const r = await tools.exec_command({cmd:"sed -n '461,920p' specs/05-BUSINESS-LOGIC.md","workdir":"/Users/fabianwesner/Herd/shop","yield_time_ms":10000,"max_output_tokens":40000});
text(r.output);