Do we cover a seed data for a demoshop? If yes, what kind of products and how many? Are all capabilities of our shop covered by the demo data?

And PSP is dummy, right? What payment methods and what shopiment methods?


The user is asking several specific questions about the seed data and payment/shipping configuration. Let me read the relevant spec files more thoroughly to answer these questions.

1. Is there seed data for a demo shop? What products? How many?
2. Are all capabilities covered by the demo data?
3. Is PSP (Payment Service Provider) dummy?
4. What payment methods?
5. What shipment methods?

I already read the beginning of spec 07. Let me read more of it, and also check spec 05 for payment/shipping details.


   100→| handle | Faker unique slug (2 words) |
   101→| status | "active" |
   102→| default_currency | "EUR" |
   103→| default_locale | "en" |
   104→| timezone | "Europe/Berlin" |
   105→
   106→**States:**
   107→
   108→| State Name | Overrides |
   109→|-----------|-----------|
   110→| `suspended` | status = "suspended" |
   111→
   112→---
   113→
   114→### 2.3 StoreDomainFactory
   115→
   116→**Model:** StoreDomain
   117→
   118→**Default definition:**
   119→
   120→| Field | Generation Rule |
   121→|-------|----------------|
   122→| store_id | Creates a new Store via its factory |
   123→| hostname | Faker unique domain name |
   124→| type | "storefront" |
   125→| is_primary | 1 |
   126→| tls_mode | "managed" |
   127→
   128→**States:**
   129→
   130→| State Name | Overrides |
   131→|-----------|-----------|
   132→| `admin` | type = "admin" |
   133→| `api` | type = "api" |
   134→| `secondary` | is_primary = 0 |
   135→
   136→---
   137→
   138→### 2.4 UserFactory
   139→
   140→**Model:** User
   141→
   142→**Default definition:**
   143→
   144→| Field | Generation Rule |
   145→|-------|----------------|
   146→| email | Faker unique safe email |
   147→| password_hash | bcrypt of "password" |
   148→| name | Faker full name |
   149→| status | "active" |
   150→| last_login_at | Random date within the last 30 days |
   151→
   152→**States:**
   153→
   154→| State Name | Overrides |
   155→|-----------|-----------|
   156→| `disabled` | status = "disabled" |
   157→
   158→---
   159→
   160→### 2.5 ProductFactory
   161→
   162→**Model:** Product
   163→
   164→**Default definition:**
   165→
   166→| Field | Generation Rule |
   167→|-------|----------------|
   168→| store_id | Creates a new Store via its factory |
   169→| title | Faker 3 random words joined |
   170→| handle | URL-slug of the title |
   171→| status | "active" |
   172→| description_html | Faker 2 paragraphs wrapped in `<p>` tags |
   173→| vendor | Faker company name |
   174→| product_type | Random from: Shirts, Pants, Shoes, Accessories, Electronics, Books |
   175→| tags | JSON array of 1-3 random picks from: new, sale, trending, popular, limited |
   176→| published_at | Current timestamp |
   177→
   178→**States:**
   179→
   180→| State Name | Overrides |
   181→|-----------|-----------|
   182→| `draft` | status = "draft", published_at = null |
   183→| `archived` | status = "archived" |
   184→
   185→**Relationship hooks:**
   186→
   187→| Hook Name | Behavior |
   188→|-----------|----------|
   189→| `withVariants(count)` | After creating the product, create `count` ProductVariant records associated with the product |
   190→| `withDefaultVariant(price)` | After creating the product, create one ProductVariant marked as default at the given price in EUR |
   191→
   192→---
   193→
   194→### 2.6 ProductVariantFactory
   195→
   196→**Model:** ProductVariant
   197→
   198→**Default definition:**
   199→
   200→| Field | Generation Rule |
   201→|-------|----------------|
   202→| product_id | Creates a new Product via its factory |
   203→| sku | Faker unique, pattern: "SKU-####-???" (digits and letters) |
   204→| barcode | Faker EAN-13 |
   205→| price_amount | Random integer 999-19999 (9.99 to 199.99 EUR) |
   206→| compare_at_amount | null |
   207→| currency | "EUR" |
   208→| weight_g | Random integer 100-5000 |
   209→| requires_shipping | 1 |
   210→| is_default | 0 |
   211→| position | 0 |
   212→| status | "active" |
   213→
   214→**States:**
   215→
   216→| State Name | Overrides |
   217→|-----------|-----------|
   218→| `onSale` | compare_at_amount = random 20000-39999, price_amount = random 9999-19999 |
   219→| `digital` | requires_shipping = 0, weight_g = 0 |
   220→| `default` | is_default = 1 |
   221→| `archived` | status = "archived" |
   222→
   223→---
   224→
   225→### 2.7 InventoryItemFactory
   226→
   227→**Model:** InventoryItem
   228→
   229→**Default definition:**
   230→
   231→| Field | Generation Rule |
   232→|-------|----------------|
   233→| store_id | Creates a new Store via its factory |
   234→| variant_id | Creates a new ProductVariant via its factory |
   235→| quantity_on_hand | Random integer 0-100 |
   236→| quantity_reserved | 0 |
   237→| policy | "deny" |
   238→
   239→**States:**
   240→
   241→| State Name | Overrides |
   242→|-----------|-----------|
   243→| `outOfStock` | quantity_on_hand = 0 |
   244→| `continuePolicy` | policy = "continue" |
   245→| `lowStock` | quantity_on_hand = random 1-3 |
   246→
   247→---
   248→
   249→### 2.8 CollectionFactory
   250→
   251→**Model:** Collection
   252→
   253→**Default definition:**
   254→
   255→| Field | Generation Rule |
   256→|-------|----------------|
   257→| store_id | Creates a new Store via its factory |
   258→| title | Faker 2 random words joined |
   259→| handle | URL-slug of the title |
   260→| description_html | Faker sentence wrapped in `<p>` tags |
   261→| type | "manual" |
   262→| status | "active" |
   263→
   264→**States:**
   265→
   266→| State Name | Overrides |
   267→|-----------|-----------|
   268→| `draft` | status = "draft" |
   269→| `archived` | status = "archived" |
   270→| `automated` | type = "automated" |
   271→
   272→---
   273→
   274→### 2.9 CartFactory
   275→
   276→**Model:** Cart
   277→
   278→**Default definition:**
   279→
   280→| Field | Generation Rule |
   281→|-------|----------------|
   282→| store_id | Creates a new Store via its factory |
   283→| customer_id | null |
   284→| currency | "EUR" |
   285→| cart_version | 1 |
   286→| status | "active" |
   287→
   288→**States:**
   289→
   290→| State Name | Overrides |
   291→|-----------|-----------|
   292→| `forCustomer` | customer_id = creates a new Customer via its factory |
   293→| `converted` | status = "converted" |
   294→| `abandoned` | status = "abandoned" |
   295→
   296→---
   297→
   298→### 2.10 CartLineFactory
   299→
   300→**Model:** CartLine
   301→
   302→**Default definition:**
   303→
   304→| Field | Generation Rule |
   305→|-------|----------------|
   306→| cart_id | Creates a new Cart via its factory |
   307→| variant_id | Creates a new ProductVariant via its factory |
   308→| quantity | Random 1-5 |
   309→| unit_price_amount | Random 999-19999 |
   310→| line_subtotal_amount | Computed: unit_price_amount * quantity |
   311→| line_discount_amount | 0 |
   312→| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |
   313→
   314→**States:** None.
   315→
   316→---
   317→
   318→### 2.11 CheckoutFactory
   319→
   320→**Model:** Checkout
   321→
   322→**Default definition:**
   323→
   324→| Field | Generation Rule |
   325→|-------|----------------|
   326→| store_id | Creates a new Store via its factory |
   327→| cart_id | Creates a new Cart via its factory |
   328→| customer_id | null |
   329→| status | "started" |
   330→| email | Faker safe email |
   331→| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code "DE", zip |
   332→| billing_address_json | null |
   333→| shipping_method_id | null |
   334→| discount_code | null |
   335→| tax_provider_snapshot_json | null |
   336→| totals_json | null |
   337→| expires_at | 24 hours from now |
   338→
   339→**States:**
   340→
   341→| State Name | Overrides |
   342→|-----------|-----------|
   343→| `completed` | status = "completed" |
   344→| `expired` | status = "expired", expires_at = 1 hour ago |
   345→
   346→---
   347→
   348→### 2.12 OrderFactory
   349→
   350→**Model:** Order
   351→
   352→**Default definition:**
   353→
   354→| Field | Generation Rule |
   355→|-------|----------------|
   356→| store_id | Creates a new Store via its factory |
   357→| customer_id | Creates a new Customer via its factory |
   358→| order_number | "#" + Faker unique integer 1001-9999 |
   359→| status | "paid" |
   360→| financial_status | "paid" |
   361→| fulfillment_status | "unfulfilled" |
   362→| currency | "EUR" |
   363→| subtotal_amount | 4998 |
   364→| discount_amount | 0 |
   365→| shipping_amount | 499 |
   366→| tax_amount | 798 |
   367→| total_amount | 5497 |
   368→| email | Faker safe email |
   369→| billing_address_json | JSON address object with German Faker data |
   370→| shipping_address_json | JSON address object with German Faker data |
   371→| placed_at | Current timestamp |
   372→
   373→**States:**
   374→
   375→| State Name | Overrides |
   376→|-----------|-----------|
   377→| `pending` | status = "pending", financial_status = "pending" |
   378→| `fulfilled` | status = "fulfilled", fulfillment_status = "fulfilled" |
   379→| `cancelled` | status = "cancelled", financial_status = "refunded" |
   380→| `refunded` | status = "refunded", financial_status = "refunded" |
   381→| `partiallyFulfilled` | fulfillment_status = "partial" |
   382→
   383→---
   384→
   385→### 2.13 OrderLineFactory
   386→
   387→**Model:** OrderLine
   388→
   389→**Default definition:**
   390→
   391→| Field | Generation Rule |
   392→|-------|----------------|
   393→| order_id | Creates a new Order via its factory |
   394→| product_id | Creates a new Product via its factory |
   395→| variant_id | Creates a new ProductVariant via its factory |
   396→| title_snapshot | Faker 3 words |
   397→| sku_snapshot | Faker pattern "SKU-####-???" |
   398→| quantity | Random 1-3 |
   399→| unit_price_amount | Random 999-19999 |
   400→| total_amount | Computed: unit_price_amount * quantity |
   401→| tax_lines_json | Empty JSON array |
   402→| discount_allocations_json | Empty JSON array |
   403→
   404→**States:** None.
   405→
   406→---
   407→
   408→### 2.14 PaymentFactory
   409→
   410→**Model:** Payment
   411→
   412→**Default definition:**
   413→
   414→| Field | Generation Rule |
   415→|-------|----------------|
   416→| order_id | Creates a new Order via its factory |
   417→| provider | "stripe" |
   418→| provider_payment_id | "pi_" + Faker 20 random alphanumeric characters |
   419→| status | "captured" |
   420→| amount | Random 999-99999 |
   421→| currency | "EUR" |
   422→| raw_json_encrypted | null |
   423→
   424→**States:**
   425→
   426→| State Name | Overrides |
   427→|-----------|-----------|
   428→| `requiresAction` | status = "requires_action" |
   429→| `authorized` | status = "authorized" |
   430→| `failed` | status = "failed" |
   431→| `refunded` | status = "refunded" |
   432→
   433→---
   434→
   435→### 2.15 RefundFactory
   436→
   437→**Model:** Refund
   438→
   439→**Default definition:**
   440→
   441→| Field | Generation Rule |
   442→|-------|----------------|
   443→| order_id | Creates a new Order via its factory |
   444→| payment_id | Creates a new Payment via its factory |
   445→| amount | Random 999-19999 |
   446→| reason | Faker sentence |
   447→| status | "processed" |
   448→| provider_refund_id | "re_" + Faker 20 random alphanumeric characters |
   449→
   450→**States:**
   451→
   452→| State Name | Overrides |
   453→|-----------|-----------|
   454→| `pending` | status = "pending", provider_refund_id = null |
   455→| `failed` | status = "failed" |
   456→
   457→---
   458→
   459→### 2.16 FulfillmentFactory
   460→
   461→**Model:** Fulfillment
   462→
   463→**Default definition:**
   464→
   465→| Field | Generation Rule |
   466→|-------|----------------|
   467→| order_id | Creates a new Order via its factory |
   468→| status | "shipped" |
   469→| tracking_company | Random from: DHL, UPS, FedEx, DPD |
   470→| tracking_number | Faker pattern "??########" (2 letters + 8 digits) |
   471→| tracking_url | "https://tracking.example.com/" + tracking_number |
   472→| shipped_at | Current timestamp |
   473→
   474→**States:**
   475→
   476→| State Name | Overrides |
   477→|-----------|-----------|
   478→| `pending` | status = "pending", tracking_number = null, tracking_url = null, shipped_at = null |
   479→| `delivered` | status = "delivered" |
   480→
   481→---
   482→
   483→### 2.16b FulfillmentLineFactory
   484→
   485→**Model:** FulfillmentLine
   486→
   487→**Default definition:**
   488→
   489→| Field | Rule | Notes |
   490→|-------|------|-------|
   491→| fulfillment_id | FK | Parent fulfillment |
   492→| order_line_id | FK | The order line being fulfilled |
   493→| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |
   494→
   495→**States:** None.
   496→
   497→---
   498→
   499→### 2.17 CustomerFactory

| 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 |
| type | "manual" |
| status | "active" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `draft` | status = "draft" |
| `archived` | status = "archived" |
| `automated` | type = "automated" |

---

### 2.9 CartFactory

**Model:** Cart

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| customer_id | null |
| currency | "EUR" |
| cart_version | 1 |
| status | "active" |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `forCustomer` | customer_id = creates a new Customer via its factory |
| `converted` | status = "converted" |
| `abandoned` | status = "abandoned" |

---

### 2.10 CartLineFactory

**Model:** CartLine

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| cart_id | Creates a new Cart via its factory |
| variant_id | Creates a new ProductVariant via its factory |
| quantity | Random 1-5 |
| unit_price_amount | Random 999-19999 |
| line_subtotal_amount | Computed: unit_price_amount * quantity |
| line_discount_amount | 0 |
| line_total_amount | Computed: line_subtotal_amount - line_discount_amount |

**States:** None.

---

### 2.11 CheckoutFactory

**Model:** Checkout

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| cart_id | Creates a new Cart via its factory |
| customer_id | null |
| status | "started" |
| email | Faker safe email |
| shipping_address_json | JSON object with Faker first_name, last_name, address1, city, country_code "DE", zip |
| billing_address_json | null |
| shipping_method_id | null |
| discount_code | null |
| tax_provider_snapshot_json | null |
| totals_json | null |
| expires_at | 24 hours from now |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `completed` | status = "completed" |
| `expired` | status = "expired", expires_at = 1 hour ago |

---

### 2.12 OrderFactory

**Model:** Order

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| customer_id | Creates a new Customer via its factory |
| order_number | "#" + Faker unique integer 1001-9999 |
| status | "paid" |
| financial_status | "paid" |
| fulfillment_status | "unfulfilled" |
| currency | "EUR" |
| subtotal_amount | 4998 |
| discount_amount | 0 |
| shipping_amount | 499 |
| tax_amount | 798 |
| total_amount | 5497 |
| email | Faker safe email |
| billing_address_json | JSON address object with German Faker data |
| shipping_address_json | JSON address object with German Faker data |
| placed_at | Current timestamp |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `pending` | status = "pending", financial_status = "pending" |
| `fulfilled` | status = "fulfilled", fulfillment_status = "fulfilled" |
| `cancelled` | status = "cancelled", financial_status = "refunded" |
| `refunded` | status = "refunded", financial_status = "refunded" |
| `partiallyFulfilled` | fulfillment_status = "partial" |

---

### 2.13 OrderLineFactory

**Model:** OrderLine

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| order_id | Creates a new Order via its factory |
| product_id | Creates a new Product via its factory |
| variant_id | Creates a new ProductVariant via its factory |
| title_snapshot | Faker 3 words |
| sku_snapshot | Faker pattern "SKU-####-???" |
| quantity | Random 1-3 |
| unit_price_amount | Random 999-19999 |
| total_amount | Computed: unit_price_amount * quantity |
| tax_lines_json | Empty JSON array |
| discount_allocations_json | Empty JSON array |

**States:** None.

---

### 2.14 PaymentFactory

**Model:** Payment

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| order_id | Creates a new Order via its factory |
| provider | "stripe" |
| provider_payment_id | "pi_" + Faker 20 random alphanumeric characters |
| status | "captured" |
| amount | Random 999-99999 |
| currency | "EUR" |
| raw_json_encrypted | null |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `requiresAction` | status = "requires_action" |
| `authorized` | status = "authorized" |
| `failed` | status = "failed" |
| `refunded` | status = "refunded" |

---

### 2.15 RefundFactory

**Model:** Refund

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| order_id | Creates a new Order via its factory |
| payment_id | Creates a new Payment via its factory |
| amount | Random 999-19999 |
| reason | Faker sentence |
| status | "processed" |
| provider_refund_id | "re_" + Faker 20 random alphanumeric characters |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `pending` | status = "pending", provider_refund_id = null |
| `failed` | status = "failed" |

---

### 2.16 FulfillmentFactory

**Model:** Fulfillment

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| order_id | Creates a new Order via its factory |
| status | "shipped" |
| tracking_company | Random from: DHL, UPS, FedEx, DPD |
| tracking_number | Faker pattern "??########" (2 letters + 8 digits) |
| tracking_url | "https://tracking.example.com/" + tracking_number |
| shipped_at | Current timestamp |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `pending` | status = "pending", tracking_number = null, tracking_url = null, shipped_at = null |
| `delivered` | status = "delivered" |

---

### 2.16b FulfillmentLineFactory

**Model:** FulfillmentLine

**Default definition:**

| Field | Rule | Notes |
|-------|------|-------|
| fulfillment_id | FK | Parent fulfillment |
| order_line_id | FK | The order line being fulfilled |
| quantity | integer, 1..max | Quantity fulfilled for this line (must not exceed order line quantity) |

**States:** None.

---

### 2.17 CustomerFactory


   100→| Create fulfillments | Yes | Yes | Yes | No |
   101→
   102→**Implementation requirements:**
   103→
   104→- Create a Laravel policy for each resource model (e.g., `ProductPolicy`, `OrderPolicy`).
   105→- Register gate definitions in a service provider.
   106→- 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.
   107→- 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.
   108→
   109→---
   110→
   111→## 2. Product Lifecycle
   112→
   113→### 2.1 Status Transitions
   114→
   115→**Enum:** `App\Enums\ProductStatus` - values: `draft`, `active`, `archived`
   116→
   117→```mermaid
   118→stateDiagram-v2
   119→    [*] --> Draft : Product created
   120→    Draft --> Active : Publish (requires variant with price > 0, title not empty)
   121→    Draft --> Archived : Archive
   122→    Active --> Archived : Archive (hide from storefront)
   123→    Archived --> Active : Re-publish (same preconditions as Draft->Active)
   124→    Active --> Draft : Revert (BLOCKED if order_lines exist)
   125→    Archived --> Draft : Revert (BLOCKED if order_lines exist)
   126→```
   127→
   128→**Transition rules:**
   129→
   130→| From | To | Preconditions |
   131→|---|---|---|
   132→| Draft | Active | At least one variant with `price_amount > 0`; product `title` is not empty |
   133→| Draft | Archived | None |
   134→| Active | Archived | None (soft transition: product hidden from storefront, preserved in order history) |
   135→| Archived | Active | Same preconditions as Draft to Active |
   136→| Active | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   137→| Archived | Draft | **BLOCKED** if any `order_lines` reference variants of this product |
   138→
   139→**Deletion rules:**
   140→- Only `draft` products with zero `order_lines` references may be hard-deleted.
   141→- Products with existing order references must be archived, never deleted, to preserve order history integrity.
   142→
   143→**Service:** `App\Services\ProductService`
   144→- Method `transitionStatus(product, newStatus)` validates the current state, checks preconditions, throws `InvalidProductTransitionException` on failure.
   145→- On activation: set `published_at` to current time if not already set.
   146→- Dispatch `ProductStatusChanged` event after successful transition.
   147→
   148→### 2.2 Variant and Option Matrix
   149→
   150→**Rules:**
   151→
   152→- A product has 0 to 3 options (e.g., Size, Color, Material), stored in `product_options`.
   153→- Each option has 1 or more values, stored in `product_option_values`.
   154→- Variants represent the cartesian product of option values (or a manually curated subset).
   155→- When a product has no options, a single "Default" variant is auto-created with `is_default = 1`.
   156→
   157→**Matrix rebuild logic (when options change):**
   158→
   159→```
   160→PROCEDURE rebuildMatrix(product):
   161→    current_option_values = load all option values grouped by option
   162→    desired_combos = cartesianProduct(current_option_values)
   163→    existing_variants = load all variants with their option value sets
   164→
   165→    FOR EACH combo IN desired_combos:
   166→        IF combo matches an existing variant's option value set:
   167→            SKIP (preserve existing variant - prices, SKUs, inventory intact)
   168→        ELSE:
   169→            CREATE new variant with default pricing from first existing variant
   170→
   171→    FOR EACH existing_variant NOT matching any desired combo:
   172→        IF variant has order_line references:
   173→            ARCHIVE variant (set status = 'archived')
   174→        ELSE:
   175→            DELETE variant
   176→```
   177→
   178→**Service:** `App\Services\VariantMatrixService`
   179→
   180→### 2.3 SKU Uniqueness
   181→
   182→- Combination of store_id (derived through the product's store) and SKU must be unique.
   183→- Enforced at application level before save.
   184→- Null or empty SKU values are exempt from the uniqueness check (multiple variants may have no SKU).
   185→- Validation: query all variants belonging to products in the same store, check for SKU collision excluding the current variant's ID.
   186→
   187→### 2.4 Handle/Slug Generation
   188→
   189→**Helper class:** `App\Support\HandleGenerator`
   190→
   191→- Auto-generate from the title by converting to a URL-safe slug (lowercase, hyphens for spaces, strip special characters).
   192→- Uniqueness enforced per store via the database unique index on `(store_id, handle)`.
   193→- On collision, append incrementing suffix: `my-product`, `my-product-1`, `my-product-2`, etc.
   194→- Merchants can manually edit the handle; on save, re-validate uniqueness.
   195→- Same logic applies to collections and pages.
   196→
   197→---
   198→
   199→## 3. Inventory Management
   200→
   201→### 3.1 Tracking Model
   202→
   203→Each `product_variant` has exactly one `inventory_item` record (created automatically when the variant is created).
   204→
   205→| Field | Description |
   206→|---|---|
   207→| `quantity_on_hand` | Total physical stock count |
   208→| `quantity_reserved` | Units held for active checkouts or pending orders |
   209→| **Available** (computed) | `quantity_on_hand - quantity_reserved` |
   210→
   211→### 3.2 Inventory Policy
   212→
   213→Per-variant policy stored in `inventory_items.policy`:
   214→
   215→| Policy | Behavior |
   216→|---|---|
   217→| `deny` | Block add-to-cart when available <= 0. Storefront displays "Out of stock". |
   218→| `continue` | Allow add-to-cart regardless of stock level. When available <= 0, storefront displays "Available on backorder". |
   219→
   220→### 3.3 Inventory Operations
   221→
   222→**Service:** `App\Services\InventoryService`
   223→
   224→All operations must execute within a database transaction.
   225→
   226→```mermaid
   227→flowchart LR
   228→    subgraph "Stock Lifecycle"
   229→        A[On Hand] -->|Reserve| B[Reserved]
   230→        B -->|Commit on payment| C[Deducted]
   231→        B -->|Release on expiry| A
   232→        C -->|Restock on refund| A
   233→    end
   234→```
   235→
   236→**Operation definitions:**
   237→
   238→| Operation | Trigger | Effect on `quantity_on_hand` | Effect on `quantity_reserved` | Pre-check |
   239→|---|---|---|---|---|
   240→| **Reserve** | Checkout enters `payment_pending` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
   241→| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
   242→| **Commit** | Payment confirmed, order created | -quantity | -quantity | None (reservation already validated) |
   243→| **Restock** | Refund processed with restock flag | +quantity | No change | None |
   244→
   245→### 3.4 Concurrency
   246→
   247→SQLite operates in single-writer mode with WAL journaling. Database transactions provide sufficient isolation. No optimistic locking or advisory locks are needed.
   248→
   249→---
   250→
   251→## 4. Cart Service
   252→
   253→### 4.1 Cart Lifecycle
   254→
   255→**Service:** `App\Services\CartService`
   256→
   257→```mermaid
   258→stateDiagram-v2
   259→    [*] --> active : Cart created
   260→    active --> converted : Checkout completed successfully
   261→    active --> abandoned : Cleanup job (14 days inactive)
   262→```
   263→
   264→**Cart identification:**
   265→- Guest users: cart ID stored in session cookie.
   266→- Authenticated customers: cart linked via `customer_id` foreign key.
   267→
   268→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.
   269→
   270→**Cart merge on login:**
   271→
   272→```
   273→PROCEDURE mergeGuestCart(guestCart, customerCart):
   274→    FOR EACH line IN guestCart.lines:
   275→        existingLine = customerCart.findLineByVariant(line.variant_id)
   276→        IF existingLine EXISTS:
   277→            existingLine.quantity = MAX(existingLine.quantity, line.quantity)
   278→            RECALCULATE existingLine amounts
   279→        ELSE:
   280→            MOVE line to customerCart
   281→    MARK guestCart as abandoned
   282→    RECALCULATE all line amounts on customerCart
   283→```
   284→
   285→### 4.2 Cart Operations
   286→
   287→**Create cart:**
   288→- Accepts: store, optional customer
   289→- Sets: store_id, currency from store's default_currency, initial cart_version = 1
   290→
   291→**Add line - validations (in order):**
   292→
   293→1. Variant exists and belongs to the current store (through its product).
   294→2. Product has status = `active`.
   295→3. Variant has status = `active`.
   296→4. Inventory policy check: if policy = `deny`, verify available >= requested quantity.
   297→5. If the variant already exists in the cart, increment existing line quantity instead of creating a duplicate.
   298→6. Set `unit_price_amount` from the variant's `price_amount`.
   299→7. Calculate: `line_subtotal_amount = unit_price_amount * quantity`.
   300→8. Set `line_discount_amount = 0` (recalculated by discount engine during checkout).
   301→9. Calculate: `line_total_amount = line_subtotal_amount - line_discount_amount`.
   302→10. Increment `cart_version`.
   303→
   304→**Update quantity:**
   305→- Validate new quantity > 0. If 0, remove the line instead.
   306→- Re-check inventory policy for the new quantity.
   307→- Recalculate line amounts.
   308→- Increment `cart_version`.
   309→
   310→**Remove line:**
   311→- Delete the `cart_lines` record.
   312→- Increment `cart_version`.
   313→
   314→### 4.3 Cart Version (Optimistic Concurrency)
   315→
   316→Every cart mutation increments `cart_version` by 1.
   317→
   318→**For API clients:**
   319→- Client sends `expected_version` with each mutation request.
   320→- If cart's current version does not match the expected version, return HTTP 409 Conflict with the current cart state in the response body.
   321→- This prevents race conditions when multiple browser tabs or devices modify the same cart.
   322→
   323→**For Livewire/session-based flows:**
   324→- Still implemented for consistency, but less critical since requests are serialized per session.
   325→
   326→### 4.4 Line Amount Calculation
   327→
   328→```
   329→line_subtotal_amount = unit_price_amount * quantity
   330→line_discount_amount = 0  (updated by discount engine during checkout)
   331→line_total_amount    = line_subtotal_amount - line_discount_amount
   332→```
   333→
   334→All values are integers in minor currency units.
   335→
   336→### 4.5 Cart Expiration
   337→
   338→**Job:** `App\Jobs\CleanupAbandonedCarts`
   339→
   340→- Schedule: daily
   341→- Finds carts where status = `active` AND updated_at < (now - 14 days). The 14-day threshold is configurable via `store_settings`.
   342→- Marks them as `abandoned`.
   343→- Releases any reserved inventory associated with active checkouts for these carts.
   344→
   345→---
   346→
   347→## 5. Pricing Engine
   348→
   349→### 5.1 Calculation Order (deterministic pipeline)
   350→
   351→**Service:** `App\Services\PricingEngine`
   352→
   353→The pricing engine follows a strict, deterministic sequence. The same inputs must always produce the same output.
   354→
   355→```mermaid
   356→flowchart TD
   357→    A["Step 1: Line Subtotals
   358→    FOR EACH line: subtotal = unit_price * quantity"] --> B["Step 2: Cart Subtotal
   359→    SUM of all line subtotals"]
   360→    B --> C["Step 3: Discount
   361→    Apply discount rules (Section 7)
   362→    Allocate proportionally across qualifying lines"]
   363→    C --> D["Step 4: Discounted Subtotal
   364→    subtotal - total_discount"]
   365→    D --> E["Step 5: Shipping
   366→    Based on selected method and rate rules (Section 9)"]
   367→    E --> F["Step 6: Tax
   368→    Calculated on discounted line amounts
   369→    + shipping if taxable (Section 8)"]
   370→    F --> G["Step 7: Total
   371→    discounted_subtotal + shipping + tax_total"]
   372→```
   373→
   374→### 5.2 Output Structure
   375→
   376→**Value Object:** `App\ValueObjects\PricingResult`
   377→
   378→| Field | Type | Description |
   379→|---|---|---|
   380→| `subtotal` | integer (cents) | Sum of all line subtotals before discount |
   381→| `discount` | integer (cents) | Total discount amount |
   382→| `shipping` | integer (cents) | Shipping cost |
   383→| `taxLines` | array of TaxLine | Individual tax line items |
   384→| `taxTotal` | integer (cents) | Sum of all tax line amounts |
   385→| `total` | integer (cents) | Final amount: discounted_subtotal + shipping + taxTotal |
   386→| `currency` | string | ISO 4217 currency code |
   387→
   388→**Value Object:** `App\ValueObjects\TaxLine`
   389→
   390→| Field | Type | Description |
   391→|---|---|---|
   392→| `name` | string | Tax label (e.g., "State Tax") |
   393→| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |
   394→| `amount` | integer (cents) | Calculated tax amount |
   395→
   396→### 5.3 Rounding Strategy
   397→
   398→- Each line's discount allocation, tax, and total are rounded independently.
   399→- Totals are computed by summing already-rounded line amounts.
   400→- This prevents accumulation of fractional cents across lines.
   401→- Any remainder from proportional discount allocation is added to the last qualifying line (largest-remainder method).
   402→
   403→### 5.4 Determinism and Snapshotting
   404→
   405→- After each calculation, store the full result in `checkouts.totals_json` as a JSON snapshot.
   406→- Recalculate on every significant state change: address change (may affect tax/shipping zones), shipping method change, discount code change.
   407→- The snapshot structure mirrors the PricingResult value object.
   408→
   409→---
   410→
   411→## 6. Checkout State Machine
   412→
   413→### 6.1 States
   414→
   415→**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_pending`, `completed`, `expired`
   416→
   417→```mermaid
   418→stateDiagram-v2
   419→    [*] --> started : Checkout created from cart
   420→    started --> addressed : Address provided
   421→    addressed --> shipping_selected : Shipping method chosen
   422→    shipping_selected --> payment_pending : Payment initiated
   423→    payment_pending --> completed : Payment confirmed
   424→    payment_pending --> expired : Timeout (24h)
   425→
   426→    started --> expired : Timeout (24h)
   427→    addressed --> expired : Timeout (24h)
   428→    shipping_selected --> expired : Timeout (24h)
   429→
   430→    note right of addressed : Triggers pricing recalculation
   431→    note right of shipping_selected : Triggers pricing recalculation
   432→    note right of payment_pending : Inventory reserved
   433→    note right of completed : Order created, inventory committed
   434→    note right of expired : Reserved inventory released
   435→```
   436→
   437→### 6.2 Transitions and Validation
   438→
   439→**Service:** `App\Services\CheckoutService`
   440→
   441→#### Transition: started -> addressed
   442→
   443→**Method:** `setAddress(checkout, addressData)`
   444→
   445→**Required data:**
   446→- `email` - valid email address
   447→- `shipping_address` with required fields:
   448→
   449→| Field | Required | Type | Notes |
   450→|---|---|---|---|
   451→| `first_name` | Yes | string | |
   452→| `last_name` | Yes | string | |
   453→| `address1` | Yes | string | |
   454→| `address2` | No | string | |
   455→| `company` | No | string | |
   456→| `city` | Yes | string | |
   457→| `province` | No | string | |
   458→| `province_code` | No | string | Used for zone matching |
   459→| `country` | Yes | string | 2-letter ISO 3166-1 code |
   460→| `postal_code` | Yes | string | |
   461→| `phone` | No | string | |
   462→
   463→**Actions:**
   464→1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
   465→2. Save email and shipping_address_json on the checkout.
   466→3. Copy shipping address to billing_address_json by default (can be overridden later).
   467→4. Recalculate pricing (shipping zones and tax regions may change based on address).
   468→5. Transition status to `addressed`.
   469→6. Dispatch `CheckoutAddressed` event.
   470→
   471→#### Transition: addressed -> shipping_selected
   472→
   473→**Method:** `setShippingMethod(checkout, shippingRateId)`
   474→
   475→**Requires:**
   476→- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
   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.
   478→
   479→**Actions:**
   480→1. Resolve available shipping zones for the address (see Section 9).
   481→2. Verify the selected rate belongs to an applicable zone.
   482→3. Save shipping_method_id on the checkout.
   483→4. Recalculate pricing with the selected shipping rate.
   484→5. Transition status to `shipping_selected`.
   485→6. Dispatch `CheckoutShippingSelected` event.
   486→
   487→#### Transition: shipping_selected -> payment_pending
   488→
   489→**Method:** `beginPayment(checkout)` - returns a PaymentSession value object
   490→
   491→**Requires:**
   492→- All previous data is valid (address, shipping method).
   493→- Totals have been calculated and stored.
   494→
   495→**Actions:**
   496→1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
   497→2. Create a payment intent or checkout session with the configured payment provider (see Section 10).
   498→3. Set `expires_at` on the checkout to current time + 24 hours.
   499→4. Transition status to `payment_pending`.
   500→5. Return the payment session data (redirect URL, client secret, etc.).
   501→
   502→#### Transition: payment_pending -> completed
   503→
   504→**Triggered by:** Successful payment webhook callback or redirect-back confirmation.
   505→
   506→**Method:** `completeCheckout(checkout, paymentData)` - returns an Order
   507→
   508→**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.
   509→
   510→**Actions (all within a single database transaction):**
   511→
   512→```
   513→PROCEDURE completeCheckout(checkout, paymentData):
   514→    BEGIN TRANSACTION
   515→
   516→    1. IF order already exists for this checkout:
   517→           RETURN existing order (idempotent)
   518→
   519→    2. Generate next sequential order number for this store
   520→
   521→    3. CREATE order record with:
   522→       - All checkout data (addresses, email, totals snapshot)
   523→       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'
   524→       - placed_at = now
   525→
   526→    4. FOR EACH cart line:
   527→       CREATE order_line with snapshot data:
   528→       - title_snapshot = product title + variant option labels
   529→       - sku_snapshot = variant SKU at time of purchase
   530→       - unit_price_amount, quantity, total_amount
   531→       - tax_lines_json, discount_allocations_json
   532→
   533→    5. CREATE payment record:
   534→       - Link to order, provider info, provider_payment_id
   535→       - status = captured, amount = order total
   536→       - Encrypt raw provider response for PCI compliance
   537→
   538→    6. FOR EACH cart line:
   539→       CALL InventoryService.commit(variant, quantity)
   540→
   541→    7. IF checkout has a discount code:
   542→       INCREMENT discount's usage_count by 1
   543→
   544→    8. SET cart status = 'converted'
   545→
   546→    9. SET checkout status = 'completed'
   547→
   548→    10. DISPATCH OrderCreated event
   549→
   550→    COMMIT TRANSACTION
   551→    RETURN order
   552→```
   553→
   554→#### Transition: Any active state -> expired
   555→
   556→**Triggered by:** `App\Jobs\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).
   557→
   558→**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.
   559→
   560→**Actions:**
   561→1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.
   562→2. Transition status to `expired`.
   563→3. Dispatch `CheckoutExpired` event.
   564→
   565→### 6.3 Events
   566→
   567→| Event | Dispatched When |
   568→|---|---|
   569→| `CheckoutAddressed` | Address saved on checkout |
   570→| `CheckoutShippingSelected` | Shipping method selected |
   571→| `CheckoutCompleted` | Payment confirmed and order created |
   572→| `CheckoutExpired` | Checkout expired due to inactivity |
   573→
   574→---
   575→
   576→## 7. Discount Engine
   577→
   578→### 7.1 Discount Types
   579→
   580→**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
   581→
   582→| Type | Behavior |
   583→|---|---|
   584→| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
   585→| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |
   586→
   587→**Stacking rules:**
   588→- Only one code discount per checkout.
   589→- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).
   590→
   591→### Discount Status Lifecycle
   592→
   593→| Status | Description | Transition |
   594→|--------|-------------|------------|
   595→| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
   596→| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
   597→| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
   598→| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |
   599→

| 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_pending` | 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_pending`, `completed`, `expired`

```mermaid
stateDiagram-v2
    [*] --> started : Checkout created from cart
    started --> addressed : Address provided
    addressed --> shipping_selected : Shipping method chosen
    shipping_selected --> payment_pending : Payment initiated
    payment_pending --> completed : Payment confirmed
    payment_pending --> 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_pending : 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 | |
| `phone` | No | string | |

**Actions:**
1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
2. Save email and shipping_address_json on the checkout.
3. Copy shipping address to billing_address_json by default (can be overridden later).
4. Recalculate pricing (shipping zones and tax regions may change based on address).
5. Transition status to `addressed`.
6. Dispatch `CheckoutAddressed` event.

#### Transition: addressed -> shipping_selected

**Method:** `setShippingMethod(checkout, shippingRateId)`

**Requires:**
- A valid shipping_rate_id that belongs to a shipping zone matching the checkout's shipping address.
- **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.

**Actions:**
1. Resolve available shipping zones for the address (see Section 9).
2. Verify the selected rate belongs to an applicable zone.
3. Save shipping_method_id on the checkout.
4. Recalculate pricing with the selected shipping rate.
5. Transition status to `shipping_selected`.
6. Dispatch `CheckoutShippingSelected` event.

#### Transition: shipping_selected -> payment_pending

**Method:** `beginPayment(checkout)` - returns a PaymentSession value object

**Requires:**
- All previous data is valid (address, shipping method).
- Totals have been calculated and stored.

**Actions:**
1. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
2. Create a payment intent or checkout session with the configured payment provider (see Section 10).
3. Set `expires_at` on the checkout to current time + 24 hours.
4. Transition status to `payment_pending`.
5. Return the payment session data (redirect URL, client secret, etc.).

#### Transition: payment_pending -> completed

**Triggered by:** Successful payment webhook callback or redirect-back confirmation.

**Method:** `completeCheckout(checkout, paymentData)` - returns an Order

**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., duplicate webhooks), it must not create duplicate orders. Always check if an order already exists for this checkout before proceeding.

**Actions (all within a single database transaction):**

```
PROCEDURE completeCheckout(checkout, paymentData):
    BEGIN TRANSACTION

    1. IF order already exists for this checkout:
           RETURN existing order (idempotent)

    2. Generate next sequential order number for this store

    3. CREATE order record with:
       - All checkout data (addresses, email, totals snapshot)
       - status = 'pending', financial_status = 'paid', fulfillment_status = 'unfulfilled'
       - placed_at = now

    4. FOR EACH cart line:
       CREATE order_line with snapshot data:
       - title_snapshot = product title + variant option labels
       - sku_snapshot = variant SKU at time of purchase
       - unit_price_amount, quantity, total_amount
       - tax_lines_json, discount_allocations_json

    5. CREATE payment record:
       - Link to order, provider info, provider_payment_id
       - status = captured, amount = order total
       - Encrypt raw provider response for PCI compliance

    6. FOR EACH cart line:
       CALL InventoryService.commit(variant, quantity)

    7. IF checkout has a discount code:
       INCREMENT discount's usage_count by 1

    8. SET cart status = 'converted'

    9. SET checkout status = 'completed'

    10. DISPATCH OrderCreated event

    COMMIT TRANSACTION
    RETURN order
```

#### Transition: Any active state -> expired

**Triggered by:** `App\Jobs\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).

**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.

**Actions:**
1. If inventory was reserved (status was `payment_pending`): release all reserved inventory via InventoryService.release.
2. Transition status to `expired`.
3. Dispatch `CheckoutExpired` event.

### 6.3 Events

| Event | Dispatched When |
|---|---|
| `CheckoutAddressed` | Address saved on checkout |
| `CheckoutShippingSelected` | Shipping method selected |
| `CheckoutCompleted` | Payment confirmed and order created |
| `CheckoutExpired` | Checkout expired due to inactivity |

---

## 7. Discount Engine

### 7.1 Discount Types

**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`

| Type | Behavior |
|---|---|
| `code` | Customer enters a code at checkout. Stored in `discounts.code`, matched case-insensitively. |
| `automatic` | Applied automatically when rule conditions are met. Evaluated on every pricing calculation. |

**Stacking rules:**
- Only one code discount per checkout.
- Multiple automatic discounts can stack (each applied sequentially to the remaining undiscounted amount).

### Discount Status Lifecycle

| Status | Description | Transition |
|--------|-------------|------------|
| `draft` | Created but not yet active. Not visible to customers, not applicable to carts. | Admin creates discount -> `draft`. Admin activates -> `active`. |
| `active` | Live and applicable to carts (subject to date range and usage limits). | Automatic transition when `starts_at` is reached (if set). Admin can manually activate. |
| `expired` | Past the `ends_at` date. No longer applicable. | Automatic transition when `ends_at` passes. Irreversible. |
| `disabled` | Manually deactivated by admin. Not applicable to carts. | Admin disables an `active` discount. Admin can re-enable -> `active`. |


   500→
   501→**Model:** Customer
   502→
   503→**Default definition:**
   504→
   505→| Field | Generation Rule |
   506→|-------|----------------|
   507→| store_id | Creates a new Store via its factory |
   508→| email | Faker unique safe email |
   509→| password_hash | bcrypt of "password" |
   510→| name | Faker full name |
   511→| marketing_opt_in | 30% chance of 1, otherwise 0 |
   512→
   513→**States:**
   514→
   515→| State Name | Overrides |
   516→|-----------|-----------|
   517→| `guest` | password_hash = null |
   518→| `optedInMarketing` | marketing_opt_in = 1 |
   519→
   520→---
   521→
   522→### 2.18 CustomerAddressFactory
   523→
   524→**Model:** CustomerAddress
   525→
   526→**Default definition:**
   527→
   528→| Field | Generation Rule |
   529→|-------|----------------|
   530→| customer_id | Creates a new Customer via its factory |
   531→| label | Random from: Home, Work, Other |
   532→| address_json | JSON object (see Address JSON Structure in Section 6) |
   533→| is_default | 0 |
   534→
   535→**States:**
   536→
   537→| State Name | Overrides |
   538→|-----------|-----------|
   539→| `default` | is_default = 1 |
   540→
   541→---
   542→
   543→### 2.19 DiscountFactory
   544→
   545→**Model:** Discount
   546→
   547→**Default definition:**
   548→
   549→| Field | Generation Rule |
   550→|-------|----------------|
   551→| store_id | Creates a new Store via its factory |
   552→| type | "code" |
   553→| code | Faker unique uppercase pattern "????##" |
   554→| value_type | "percent" |
   555→| value_amount | 10 |
   556→| starts_at | 1 month ago |
   557→| ends_at | 1 year from now |
   558→| usage_limit | null |
   559→| usage_count | 0 |
   560→| rules_json | Empty JSON object |
   561→| status | "active" |
   562→
   563→**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.
   564→
   565→**States:**
   566→
   567→| State Name | Overrides |
   568→|-----------|-----------|
   569→| `fixed(amountCents)` | value_type = "fixed", value_amount = amountCents |
   570→| `freeShipping` | value_type = "free_shipping", value_amount = 0 |
   571→| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = "expired" |
   572→| `maxedOut` | usage_limit = 5, usage_count = 5 |
   573→| `automatic` | type = "automatic", code = null |
   574→| `draft` | status = "draft" |
   575→| `disabled` | status = "disabled", starts_at = past date |
   576→
   577→---
   578→
   579→### 2.20 ShippingZoneFactory
   580→
   581→**Model:** ShippingZone
   582→
   583→**Default definition:**
   584→
   585→| Field | Generation Rule |
   586→|-------|----------------|
   587→| store_id | Creates a new Store via its factory |
   588→| name | Faker country name |
   589→| countries_json | JSON array: ["DE"] |
   590→| regions_json | Empty JSON array |
   591→
   592→**States:** None.
   593→
   594→---
   595→
   596→### 2.21 ShippingRateFactory
   597→
   598→**Model:** ShippingRate
   599→
   600→**Default definition:**
   601→
   602→| Field | Generation Rule |
   603→|-------|----------------|
   604→| zone_id | Creates a new ShippingZone via its factory |
   605→| name | Random from: Standard, Express, Economy |
   606→| type | "flat" |
   607→| config_json | JSON object with amount = 499 |
   608→| is_active | 1 |
   609→
   610→**States:**
   611→
   612→| State Name | Overrides |
   613→|-----------|-----------|
   614→| `inactive` | is_active = 0 |
   615→| `weightBased` | type = "weight", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |
   616→
   617→---
   618→
   619→### 2.22 ThemeFactory
   620→
   621→**Model:** Theme
   622→
   623→**Default definition:**
   624→
   625→| Field | Generation Rule |
   626→|-------|----------------|
   627→| store_id | Creates a new Store via its factory |
   628→| name | "Default Theme" |
   629→| version | "1.0.0" |
   630→| status | "published" |
   631→| published_at | Current timestamp |
   632→
   633→**States:**
   634→
   635→| State Name | Overrides |
   636→|-----------|-----------|
   637→| `draft` | status = "draft", published_at = null |
   638→
   639→---
   640→
   641→### 2.23 PageFactory
   642→
   643→**Model:** Page
   644→
   645→**Default definition:**
   646→
   647→| Field | Generation Rule |
   648→|-------|----------------|
   649→| store_id | Creates a new Store via its factory |
   650→| title | Faker 3 random words |
   651→| handle | URL-slug of the title |
   652→| body_html | Faker heading + 3 paragraphs in HTML tags |
   653→| status | "published" |
   654→| published_at | Current timestamp |
   655→
   656→**States:**
   657→
   658→| State Name | Overrides |
   659→|-----------|-----------|
   660→| `draft` | status = "draft", published_at = null |
   661→| `archived` | status = "archived" |
   662→
   663→---
   664→
   665→### 2.24 NavigationMenuFactory
   666→
   667→**Model:** NavigationMenu
   668→
   669→**Default definition:**
   670→
   671→| Field | Generation Rule |
   672→|-------|----------------|
   673→| store_id | Creates a new Store via its factory |
   674→| handle | Faker unique slug (2 words) |
   675→| title | Faker 2 words |
   676→
   677→**States:** None.
   678→
   679→---
   680→
   681→### 2.25 NavigationItemFactory
   682→
   683→**Model:** NavigationItem
   684→
   685→**Default definition:**
   686→
   687→| Field | Generation Rule |
   688→|-------|----------------|
   689→| menu_id | Creates a new NavigationMenu via its factory |
   690→| type | "link" |
   691→| label | Faker 2 words |
   692→| url | "/" |
   693→| resource_id | null |
   694→| position | 0 |
   695→
   696→**States:**
   697→
   698→| State Name | Overrides |
   699→|-----------|-----------|
   700→| `page(pageId)` | type = "page", url = null, resource_id = pageId |
   701→| `collection(collectionId)` | type = "collection", url = null, resource_id = collectionId |
   702→| `product(productId)` | type = "product", url = null, resource_id = productId |
   703→
   704→---
   705→
   706→### 2.26 AnalyticsEventFactory
   707→
   708→**Model:** AnalyticsEvent
   709→
   710→**Default definition:**
   711→
   712→| Field | Generation Rule |
   713→|-------|----------------|
   714→| store_id | Creates a new Store via its factory |
   715→| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |
   716→| session_id | Faker UUID |
   717→| customer_id | null |
   718→| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |
   719→| created_at | Random timestamp within last 7 days |
   720→
   721→**States:**
   722→
   723→| State Name | Overrides |
   724→|-----------|-----------|
   725→| `pageView` | type = "page_view" |
   726→| `productView` | type = "product_view", properties_json includes product_id and product_title |
   727→| `addToCart` | type = "add_to_cart", properties_json includes variant_id and quantity |
   728→
   729→---
   730→
   731→## 3. Demo Seed Scenario (Exact Data)
   732→
   733→All seeders below create deterministic, named records required by E2E tests. References like "Product 1" in order lines refer to products by their seed position.
   734→
   735→---
   736→
   737→### 3.1 OrganizationSeeder
   738→
   739→Creates one organization.
   740→
   741→| Field | Value |
   742→|-------|-------|
   743→| name | Acme Corp |
   744→| billing_email | billing@acme.test |
   745→
   746→---
   747→
   748→### 3.2 StoreSeeder
   749→
   750→Creates two stores under "Acme Corp".
   751→
   752→**Store 1: Acme Fashion**
   753→
   754→| Field | Value |
   755→|-------|-------|
   756→| name | Acme Fashion |
   757→| handle | acme-fashion |
   758→| status | active |
   759→| default_currency | EUR |
   760→| default_locale | en |
   761→| timezone | Europe/Berlin |
   762→
   763→**Store 2: Acme Electronics**
   764→
   765→| Field | Value |
   766→|-------|-------|
   767→| name | Acme Electronics |
   768→| handle | acme-electronics |
   769→| status | active |
   770→| default_currency | EUR |
   771→| default_locale | en |
   772→| timezone | Europe/Berlin |
   773→
   774→---
   775→
   776→### 3.3 StoreDomainSeeder
   777→
   778→**Acme Fashion domains:**
   779→
   780→| Hostname | Type | is_primary | tls_mode |
   781→|----------|------|-----------|----------|
   782→| acme-fashion.test | storefront | 1 | managed |
   783→| admin.acme-fashion.test | admin | 0 | managed |
   784→
   785→**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.
   786→
   787→**Acme Electronics domains:**
   788→
   789→| Hostname | Type | is_primary | tls_mode |
   790→|----------|------|-----------|----------|
   791→| acme-electronics.test | storefront | 1 | managed |
   792→
   793→---
   794→
   795→### 3.4 UserSeeder
   796→
   797→| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |
   798→|-------|------|----------------------------------------------|--------|---------------|
   799→| admin@acme.test | Admin User | password | active | now |
   800→| staff@acme.test | Staff User | password | active | 2 days ago |
   801→| support@acme.test | Support User | password | active | 1 day ago |
   802→| manager@acme.test | Store Manager | password | active | 1 day ago |
   803→| admin2@acme.test | Admin Two | password | active | 1 day ago |
   804→
   805→---
   806→
   807→### 3.5 StoreUserSeeder
   808→
   809→| User Email | Store | Role |
   810→|------------|-------|------|
   811→| admin@acme.test | Acme Fashion | owner |
   812→| staff@acme.test | Acme Fashion | staff |
   813→| support@acme.test | Acme Fashion | support |
   814→| manager@acme.test | Acme Fashion | admin |
   815→| admin2@acme.test | Acme Electronics | owner |
   816→
   817→---
   818→
   819→### 3.6 StoreSettingsSeeder
   820→
   821→**Acme Fashion - settings_json contents:**
   822→
   823→| Setting Key | Value |
   824→|-------------|-------|
   825→| store_name | Acme Fashion |
   826→| contact_email | hello@acme-fashion.test |
   827→| order_number_prefix | # |
   828→| order_number_start | 1001 |
   829→
   830→**Acme Electronics - settings_json contents:**
   831→
   832→| Setting Key | Value |
   833→|-------------|-------|
   834→| store_name | Acme Electronics |
   835→| contact_email | hello@acme-electronics.test |
   836→| order_number_prefix | # |
   837→| order_number_start | 5001 |
   838→
   839→---
   840→
   841→### 3.7 TaxSettingsSeeder
   842→
   843→**Acme Fashion:**
   844→
   845→| Field | Value |
   846→|-------|-------|
   847→| mode | manual |
   848→| provider | none |
   849→| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |
   850→| config_json | default_rate_bps: 1900 |
   851→
   852→Tax rate: 19% (1900 basis points) applied to all zones.
   853→
   854→**Acme Electronics:** Same as Acme Fashion.
   855→
   856→---
   857→
   858→### 3.8 ShippingSeeder
   859→
   860→**Acme Fashion - Shipping Zones and Rates:**
   861→
   862→**Zone 1: "Domestic"**
   863→- countries_json: ["DE"]
   864→- regions_json: []
   865→
   866→| Rate Name | Type | config_json (amount in cents) | is_active |
   867→|-----------|------|-------------------------------|-----------|
   868→| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |
   869→| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |
   870→
   871→**Zone 2: "EU"**
   872→- countries_json: ["AT", "FR", "IT", "ES", "NL", "BE", "PL"]
   873→- regions_json: []
   874→
   875→| Rate Name | Type | config_json (amount in cents) | is_active |
   876→|-----------|------|-------------------------------|-----------|
   877→| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |
   878→
   879→**Zone 3: "Rest of World"**
   880→- countries_json: ["US", "GB", "CA", "AU"]
   881→- regions_json: []
   882→
   883→| Rate Name | Type | config_json (amount in cents) | is_active |
   884→|-----------|------|-------------------------------|-----------|
   885→| International | flat | amount: 1499 (14.99 EUR) | 1 |
   886→
   887→**Acme Electronics - One zone:**
   888→
   889→**Zone: "Germany"**
   890→- countries_json: ["DE"]
   891→
   892→| Rate Name | Type | config_json | is_active |
   893→|-----------|------|-------------|-----------|
   894→| Standard | flat | amount: 0 (free) | 1 |
   895→
   896→---
   897→
   898→### 3.9 CollectionSeeder
   899→
   900→**Acme Fashion - 4 collections:**
   901→
   902→| # | Title | Handle | Type | Status | description_html |
   903→|---|-------|--------|------|--------|------------------|
   904→| 1 | New Arrivals | new-arrivals | manual | active | "Discover the latest additions to our store." in `<p>` tag |
   905→| 2 | T-Shirts | t-shirts | manual | active | "Premium cotton tees for every occasion." in `<p>` tag |
   906→| 3 | Pants & Jeans | pants-jeans | manual | active | "Find the perfect fit from our denim and trouser range." in `<p>` tag |
   907→| 4 | Sale | sale | manual | active | "Great deals on selected items." in `<p>` tag |
   908→
   909→**Acme Electronics - 2 collections:**
   910→
   911→| # | Title | Handle | Type | Status |
   912→|---|-------|--------|------|--------|
   913→| 1 | Featured | featured | manual | active |
   914→| 2 | Accessories | accessories | manual | active |
   915→
   916→---
   917→
   918→### 3.10 ProductSeeder
   919→
   920→#### Acme Fashion - 20 Products
   921→
   922→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.
   923→
   924→---
   925→
   926→**Product 1: "Classic Cotton T-Shirt"**
   927→
   928→| Field | Value |
   929→|-------|-------|
   930→| handle | classic-cotton-t-shirt |
   931→| status | active |
   932→| vendor | Acme Basics |
   933→| product_type | T-Shirts |
   934→| tags | ["new", "popular"] |
   935→| description_html | "A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear." in `<p>` tag |
   936→| published_at | now |
   937→
   938→Collections: New Arrivals, T-Shirts
   939→
   940→Options and values:
   941→
   942→| Option Name | Values (in position order) |
   943→|-------------|---------------------------|
   944→| Size | S, M, L, XL |
   945→| Color | White, Black, Navy |
   946→
   947→Variants (12 total - all size x color combinations):
   948→- SKU pattern: ACME-CTSH-{SIZE}-{COLOR} (e.g. ACME-CTSH-S-WHT, ACME-CTSH-S-BLK, etc.)
   949→- First variant (S/White) is marked is_default = 1
   950→- Position increments 0 through 11
   951→
   952→| Per-variant field | Value |
   953→|-------------------|-------|
   954→| price_amount | 2499 (24.99 EUR) |
   955→| compare_at_amount | null |
   956→| currency | EUR |
   957→| weight_g | 200 |
   958→| requires_shipping | 1 |
   959→
   960→Inventory per variant: quantity_on_hand = 15, policy = "deny"
   961→
   962→---
   963→
   964→**Product 2: "Premium Slim Fit Jeans"**
   965→
   966→| Field | Value |
   967→|-------|-------|
   968→| handle | premium-slim-fit-jeans |
   969→| status | active |
   970→| vendor | Acme Denim |
   971→| product_type | Pants |
   972→| tags | ["new", "sale"] |
   973→| description_html | "Slim fit jeans crafted from premium stretch denim. Comfortable all-day wear with a modern silhouette." in `<p>` tag |
   974→| published_at | now |
   975→
   976→Collections: New Arrivals, Pants & Jeans, Sale
   977→
   978→Options and values:
   979→
   980→| Option Name | Values |
   981→|-------------|--------|
   982→| Size | 28, 30, 32, 34, 36 |
   983→| Color | Blue, Black |
   984→
   985→Variants (10 total): First variant (28/Blue) is default.
   986→
   987→| Per-variant field | Value |
   988→|-------------------|-------|
   989→| price_amount | 7999 (79.99 EUR) |
   990→| compare_at_amount | 9999 (99.99 EUR - on sale) |
   991→| weight_g | 800 |
   992→| requires_shipping | 1 |
   993→
   994→Inventory per variant: quantity_on_hand = 8, policy = "deny"
   995→
   996→---
   997→
   998→**Product 3: "Organic Hoodie"**
   999→

**Model:** Customer

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| email | Faker unique safe email |
| password_hash | bcrypt of "password" |
| name | Faker full name |
| marketing_opt_in | 30% chance of 1, otherwise 0 |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `guest` | password_hash = null |
| `optedInMarketing` | marketing_opt_in = 1 |

---

### 2.18 CustomerAddressFactory

**Model:** CustomerAddress

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| customer_id | Creates a new Customer via its factory |
| label | Random from: Home, Work, Other |
| address_json | JSON object (see Address JSON Structure in Section 6) |
| is_default | 0 |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `default` | is_default = 1 |

---

### 2.19 DiscountFactory

**Model:** Discount

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| type | "code" |
| code | Faker unique uppercase pattern "????##" |
| value_type | "percent" |
| value_amount | 10 |
| starts_at | 1 month ago |
| ends_at | 1 year from now |
| usage_limit | null |
| usage_count | 0 |
| rules_json | Empty JSON object |
| status | "active" |

**Note on value_amount for percent type:** Stored as whole number percentages. 10 = 10%, 20 = 20%.

**States:**

| State Name | Overrides |
|-----------|-----------|
| `fixed(amountCents)` | value_type = "fixed", value_amount = amountCents |
| `freeShipping` | value_type = "free_shipping", value_amount = 0 |
| `expired` | starts_at = 1 year ago, ends_at = yesterday, status = "expired" |
| `maxedOut` | usage_limit = 5, usage_count = 5 |
| `automatic` | type = "automatic", code = null |
| `draft` | status = "draft" |
| `disabled` | status = "disabled", starts_at = past date |

---

### 2.20 ShippingZoneFactory

**Model:** ShippingZone

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| name | Faker country name |
| countries_json | JSON array: ["DE"] |
| regions_json | Empty JSON array |

**States:** None.

---

### 2.21 ShippingRateFactory

**Model:** ShippingRate

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| zone_id | Creates a new ShippingZone via its factory |
| name | Random from: Standard, Express, Economy |
| type | "flat" |
| config_json | JSON object with amount = 499 |
| is_active | 1 |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `inactive` | is_active = 0 |
| `weightBased` | type = "weight", config_json has ranges array with tiers: 0-500g = 399, 501-2000g = 699, 2001g+ = 1299 |

---

### 2.22 ThemeFactory

**Model:** Theme

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| name | "Default Theme" |
| version | "1.0.0" |
| status | "published" |
| published_at | Current timestamp |

**States:**

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

---

### 2.23 PageFactory

**Model:** Page

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| title | Faker 3 random words |
| handle | URL-slug of the title |
| body_html | Faker heading + 3 paragraphs in HTML tags |
| status | "published" |
| published_at | Current timestamp |

**States:**

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

---

### 2.24 NavigationMenuFactory

**Model:** NavigationMenu

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| handle | Faker unique slug (2 words) |
| title | Faker 2 words |

**States:** None.

---

### 2.25 NavigationItemFactory

**Model:** NavigationItem

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| menu_id | Creates a new NavigationMenu via its factory |
| type | "link" |
| label | Faker 2 words |
| url | "/" |
| resource_id | null |
| position | 0 |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `page(pageId)` | type = "page", url = null, resource_id = pageId |
| `collection(collectionId)` | type = "collection", url = null, resource_id = collectionId |
| `product(productId)` | type = "product", url = null, resource_id = productId |

---

### 2.26 AnalyticsEventFactory

**Model:** AnalyticsEvent

**Default definition:**

| Field | Generation Rule |
|-------|----------------|
| store_id | Creates a new Store via its factory |
| type | Random from: page_view, product_view, add_to_cart, remove_from_cart, checkout_started, checkout_completed, search |
| session_id | Faker UUID |
| customer_id | null |
| properties_json | JSON with url (Faker slug) and referrer (40% chance Faker URL, otherwise null) |
| created_at | Random timestamp within last 7 days |

**States:**

| State Name | Overrides |
|-----------|-----------|
| `pageView` | type = "page_view" |
| `productView` | type = "product_view", properties_json includes product_id and product_title |
| `addToCart` | type = "add_to_cart", properties_json includes variant_id and quantity |

---

## 3. Demo Seed Scenario (Exact Data)

All seeders below create deterministic, named records required by E2E tests. References like "Product 1" in order lines refer to products by their seed position.

---

### 3.1 OrganizationSeeder

Creates one organization.

| Field | Value |
|-------|-------|
| name | Acme Corp |
| billing_email | billing@acme.test |

---

### 3.2 StoreSeeder

Creates two stores under "Acme Corp".

**Store 1: Acme Fashion**

| Field | Value |
|-------|-------|
| name | Acme Fashion |
| handle | acme-fashion |
| status | active |
| default_currency | EUR |
| default_locale | en |
| timezone | Europe/Berlin |

**Store 2: Acme Electronics**

| Field | Value |
|-------|-------|
| name | Acme Electronics |
| handle | acme-electronics |
| status | active |
| default_currency | EUR |
| default_locale | en |
| timezone | Europe/Berlin |

---

### 3.3 StoreDomainSeeder

**Acme Fashion domains:**

| Hostname | Type | is_primary | tls_mode |
|----------|------|-----------|----------|
| acme-fashion.test | storefront | 1 | managed |
| admin.acme-fashion.test | admin | 0 | managed |

**Note:** The admin domain (`admin.acme-fashion.test`) routes HTTP requests to the admin panel. However, the actual store context within the admin panel is determined by the session key `current_store_id`, set after login or via the store-switcher. The admin domain itself does not determine store scope.

**Acme Electronics domains:**

| Hostname | Type | is_primary | tls_mode |
|----------|------|-----------|----------|
| acme-electronics.test | storefront | 1 | managed |

---

### 3.4 UserSeeder

| Email | Name | Password (plaintext, stored as bcrypt hash) | Status | last_login_at |
|-------|------|----------------------------------------------|--------|---------------|
| admin@acme.test | Admin User | password | active | now |
| staff@acme.test | Staff User | password | active | 2 days ago |
| support@acme.test | Support User | password | active | 1 day ago |
| manager@acme.test | Store Manager | password | active | 1 day ago |
| admin2@acme.test | Admin Two | password | active | 1 day ago |

---

### 3.5 StoreUserSeeder

| User Email | Store | Role |
|------------|-------|------|
| admin@acme.test | Acme Fashion | owner |
| staff@acme.test | Acme Fashion | staff |
| support@acme.test | Acme Fashion | support |
| manager@acme.test | Acme Fashion | admin |
| admin2@acme.test | Acme Electronics | owner |

---

### 3.6 StoreSettingsSeeder

**Acme Fashion - settings_json contents:**

| Setting Key | Value |
|-------------|-------|
| store_name | Acme Fashion |
| contact_email | hello@acme-fashion.test |
| order_number_prefix | # |
| order_number_start | 1001 |

**Acme Electronics - settings_json contents:**

| Setting Key | Value |
|-------------|-------|
| store_name | Acme Electronics |
| contact_email | hello@acme-electronics.test |
| order_number_prefix | # |
| order_number_start | 5001 |

---

### 3.7 TaxSettingsSeeder

**Acme Fashion:**

| Field | Value |
|-------|-------|
| mode | manual |
| provider | none |
| prices_include_tax | 1 (prices shown to customers are gross/tax-inclusive) |
| config_json | default_rate_bps: 1900 |

Tax rate: 19% (1900 basis points) applied to all zones.

**Acme Electronics:** Same as Acme Fashion.

---

### 3.8 ShippingSeeder

**Acme Fashion - Shipping Zones and Rates:**

**Zone 1: "Domestic"**
- countries_json: ["DE"]
- regions_json: []

| Rate Name | Type | config_json (amount in cents) | is_active |
|-----------|------|-------------------------------|-----------|
| Standard Shipping | flat | amount: 499 (4.99 EUR) | 1 |
| Express Shipping | flat | amount: 999 (9.99 EUR) | 1 |

**Zone 2: "EU"**
- countries_json: ["AT", "FR", "IT", "ES", "NL", "BE", "PL"]
- regions_json: []

| Rate Name | Type | config_json (amount in cents) | is_active |
|-----------|------|-------------------------------|-----------|
| EU Standard | flat | amount: 899 (8.99 EUR) | 1 |

**Zone 3: "Rest of World"**
- countries_json: ["US", "GB", "CA", "AU"]
- regions_json: []

| Rate Name | Type | config_json (amount in cents) | is_active |
|-----------|------|-------------------------------|-----------|
| International | flat | amount: 1499 (14.99 EUR) | 1 |

**Acme Electronics - One zone:**

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