command: sed -n '260,620p' specs/05-BUSINESS-LOGIC.md

aggregated_output:
    active --> converted : Checkout completed successfully
    active --> abandoned : Cleanup job (14 days inactive)
```

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

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

**Cart merge on login:**

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

### 4.2 Cart Operations

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

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

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

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

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

### 4.3 Cart Version (Optimistic Concurrency)

Every cart mutation increments `cart_version` by 1.

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

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

### 4.4 Line Amount Calculation

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

All values are integers in minor currency units.

### 4.5 Cart Expiration

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

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

---

## 5. Pricing Engine

### 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 |…3890 chars truncated…d stores the selected payment method

**Requires:**
- All previous data is valid (address, shipping method).
- Totals have been calculated and stored.
- `paymentMethod` must be one of: `credit_card`, `paypal`, `bank_transfer`.

**Actions:**
1. Store `payment_method` on the checkout record.
2. Reserve inventory for all cart lines (call InventoryService.reserve for each line).
3. Set `expires_at` on the checkout to current time + 24 hours.
4. Transition status to `payment_selected`.

#### Transition: payment_selected -> completed

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

**Triggered by:** Customer submitting the payment form (onsite, no external redirect).

**CRITICAL: This transition must be idempotent.** If called multiple times for the same checkout (e.g., double-click), 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, paymentMethodData):
    BEGIN TRANSACTION

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

    2. CALL MockPaymentProvider.charge(checkout, paymentMethodData)
       IF result.success = false:
           Release reserved inventory
           THROW PaymentFailedException(result.errorCode)

    3. Generate next sequential order number for this store

    4. DETERMINE order statuses based on payment method:
       IF checkout.payment_method IN ('credit_card', 'paypal'):
           orderStatus = 'paid'
           financialStatus = 'paid'
           paymentStatus = 'captured'
           inventoryAction = 'commit'
       ELSE IF checkout.payment_method = 'bank_transfer':
           orderStatus = 'pending'
           financialStatus = 'pending'
           paymentStatus = 'pending'
           inventoryAction = 'keep_reserved'

    5. CREATE order record with:
       - All checkout data (addresses, email, totals snapshot)
       - payment_method = checkout.payment_method
       - status = orderStatus, financial_status = financialStatus
       - fulfillment_status = 'unfulfilled'
       - placed_at = now

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

    7. CREATE payment record:
       - Link to order, provider = 'mock', method = checkout.payment_method
       - provider_payment_id = mock reference ID
       - status = paymentStatus, amount = order total

    8. IF inventoryAction = 'commit':
       FOR EACH cart line:
           CALL InventoryService.commit(variant, quantity)
       // Note: for bank_transfer, inventory stays reserved until admin confirms payment

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

    10. SET cart status = 'converted'

    11. SET checkout status = 'completed'

    12. IF inventoryAction = 'commit' AND all order lines are digital:
        Auto-create fulfillment (see Section 11.7)

    13. 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_selected`): 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. |


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

type: read

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

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

**Indexes:**

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

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

---

### inventory_items

Stock tracking per variant per store.

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

**Indexes:**

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

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

---

### collections

Curated or rule-based product groupings.

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

**Indexes:**

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

---

### collection_products

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

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

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

**Indexes:**

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

---

### product_media

Images and videos attached to a product.

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

**Indexes:**

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

---

## Epic 3 - Themes, Storefront, and Localization

### themes

Storefront theme definitions per stor…12320 chars truncated…# tax_settings

Per-store tax configuration.

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

---

### discounts

Discount codes and automatic discounts.

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

**Indexes:**

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

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

---

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

### customers

Storefront customer accounts, scoped to a store.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| email | TEXT | No | - | UNIQUE per (store_id, email) | Customer email |
| password_hash | TEXT | Yes | NULL | - | Nullable for guest checkout customers |
| name | TEXT | Yes | NULL | - | Display name |
| marketing_opt_in | INTEGER | No | 0 | - | Boolean: marketing consent |
| created_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |
| updated_at | TEXT | Yes | NULL | - | ISO-8601 timestamp |

**Indexes:**

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

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

---

### customer_addresses

Saved addresses for a customer.

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

**Indexes:**

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

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

---

### orders

Placed orders with financial and fulfillment tracking.

| Column | Type | Nullable | Default | Constraints | Notes |
|---|---|---|---|---|---|
| id | INTEGER | No | autoincrement | PRIMARY KEY | |
| store_id | INTEGER | No | - | FK -> stores(id) ON DELETE CASCADE | Owning store |
| customer_id | INTEGER | Yes | NULL | FK -> customers(id) ON DELETE SET NULL | Nullable for guest orders |
| order_number | TEXT | No | - | UNIQUE per (store_id, order_number) | Human-readable order number |

status: completed


command: sed -n '260,760p' specs/04-STOREFRONT-UI.md

aggregated_output:
|       [CTA Button - primary, large]                           |
|                                                               |
+--------------------------------------------------------------+
```

| Attribute | Description |
|---|---|
| Background | Full-width image (cover, centered) or gradient. Image set via theme settings. Semi-transparent dark overlay for text readability |
| Content | Centered vertically and horizontally, white text |
| Heading | H1 or H2 depending on context. Very large on desktop, progressively smaller on mobile. Bold, tight tracking |
| Subheading | Regular paragraph, medium size, generous top margin. Constrained to a comfortable reading width |
| CTA | Styled as primary button, large. Links to a configured collection or product |
| Height | Minimum 600px on desktop, 500px on tablet, 400px on mobile, 300px on small mobile |
| Padding | Generous vertical and horizontal padding |

**Mobile:** Same structure but stacked, with reduced font sizes and shorter minimum height. Full-bleed background image remains.

**Accessibility:**

- If background is an image, it is decorative (the content provides meaning)
- CTA button has descriptive text (e.g., "Shop the summer collection")

### 3.2 Featured Collections Section

**Layout:** Section with heading and grid of collection cards.

| Attribute | Description |
|---|---|
| Section heading | H2, large, bold, centered, with generous bottom margin |
| Grid | 2 columns on mobile, 4 columns on large screens, with gap spacing |
| Count | 2 to 4 collections, configurable in theme settings |

**Collection Card:**

```
+---------------------------+
|  [Collection Image]       |
|  3:4 aspect ratio         |
|  cover-fit, rounded       |
|                           |
|  [Title overlay at bottom]|
|  [Shop now ->]            |
+---------------------------+
```

| Element | Description |
|---|---|
| Image | 3:4 aspect ratio, cover-fit, rounded corners. Gradient overlay at bottom fading from transparent to semi-transparent dark |
| Title | White text overlaid at bottom of image, semi-bold, medium-large size |
| "Shop now" link | Small white text below title, slightly transparent, underlined. Becomes fully opaque on hover |
| Interaction | Entire card is clickable. Subtle scale-up on hover with smooth transition |

**Accessibility:**

- Each card link has the collection title as accessible name
- Images have alt text matching the collection title

### 3.3 Featured Products Section

**Livewire Component:** FeaturedProducts

This section uses Livewire for lazy loading to improve initial page load.

| Attribute | Description |
|---|---|
| Section heading | H2, same styling as featured collections heading |
| Grid | 2 columns on mobile, 3 on tablet, 4 on desktop, with gap spacing |
| Products | 4 to 8, configurable. Sourced from a configured collection or "best sellers" logic |
| Loading state | Skeleton placeholders with pulsing animation, matching the product card dimensions |

Each product uses the product-card component (see Section 16).

### 3.4 Newsletter Signup Section

**Livewire Component:** NewsletterSignup

**Layout:**

```
+--------------------------------------------------------------+
|  [Heading: "Stay in the loop"]                                |
|  [Subtext: "Subscribe for exclusive offers and updates."]     |
|  [Email input] [Subscribe button]                             |
+--------------------------------------------------------------+
```

| Attribute | Description |
|---|---|
| Background | Light gray (dark mode: darker gray), generous vertical padding |
| Content | Centered, constrained to a comfortable width |
| Heading | H2, medium-large, bold |
| Subtext | Muted color, small top margin |
| Form | Horizontal layout with gap. Input takes remaining width, button shrinks to fit |
| Input | Email type, placeholder "Enter your email", required |
| Button | Primary style, "Subscribe" text |
| Success state | Replaces form with "Thanks for subscribing!" message |
| Error state | Inline error below input in red, small text |
| Loading state | Button text changes to "Subscribing..." during submission |

**Accessibility:**

- Label associated with email input (visually hidden if using placeholder)
- Success/error messages in a polite live region for screen readers

### 3.5 Rich Text Section

Simple section rendering customizable content from theme settings.

| Attribute | Description |
|---|---|
| Container | Constrained to comfortable reading width, centered, vertical padding |
| Content | Sanitized HTML from theme settings, styled with typography (prose) styling for headings, paragraphs, lists, links |
| Dark mode | Inverted prose styling |

---

## 4. Collection Page (GET /collections/{handle})

**Livewire Component:** CollectionPage

### 4.1 Collection Header

| Element | Description |
|---|---|
| Breadcrumbs | Home > Collections > {Collection Title} |
| Title | H1, large, bold |
| Description | Rendered HTML from collection description,…8349 chars truncated…indicator |
| Region | Gallery wrapped in a labeled region ("Product images") |

### 5.3 Product Info (Right Column)

#### Breadcrumbs

Home > {Primary Collection} > {Product Title}. Small, muted text with separator characters.

#### Product Title

H1, large, bold.

#### Price Display

| Element | Description |
|---|---|
| Current price | Large, bold |
| Compare-at price | Medium, muted, strikethrough (shown when set and higher than current price) |
| Sale badge | Inline badge next to the price |
| Currency | Formatted based on the store's default currency |
| Live updates | Price container is a live region, so price changes when variant changes are announced to screen readers |

#### Variant Selector

For each product option (e.g., Size, Color), render a selector group.

**Radio button style (for options with 6 or fewer values):**

```
Size
[S] [M] [L] [XL] [XXL]
```

| State | Visual Description |
|---|---|
| Available | Bordered pill shape, pointer cursor, border darkens on hover |
| Selected | Primary color border with subtle background tint and focus ring |
| Unavailable | Muted border, muted text, strikethrough, reduced opacity, not-allowed cursor |

**Select dropdown style:** Used when the option has more than 6 values.

**Color swatches (when option name is "Color"):**

| Attribute | Description |
|---|---|
| Shape | Circular, 32x32px |
| Color | Determined by value name mapped to a color lookup or inline style |
| Selected state | Ring with offset around the swatch |
| Tooltip | Shows color name on hover |

**Livewire behavior:**

- Selected options are a key-value map (e.g., Size = M, Color = Red)
- Selected variant is computed by matching all selected options to the variant option values
- On change: updates price, stock messaging, available images, and add-to-cart availability

#### Stock Messaging

Displayed below the variant selector.

| Condition | Message | Visual Style |
|---|---|---|
| In stock, quantity > 10 | "In stock" | Green text with check icon |
| In stock, quantity <= 10 | "Only X left in stock" | Amber text with warning icon |
| Out of stock, deny policy | "Out of stock" | Red text with X icon |
| Out of stock, continue policy | "Available on backorder" | Blue text with info icon |

Small text, icon + text inline. Wrapped in a live region for screen reader announcements on variant change.

#### Quantity Selector

Uses the quantity-selector component.

```
[-] [3] [+]
```

| Attribute | Description |
|---|---|
| Container | Inline flex, bordered, rounded |
| Decrease button | "-" icon, 40x40px. Disabled at minimum (1) |
| Input | Centered number input, 56px wide, 40px tall. Min = 1, Max = available stock (deny policy) or unlimited (continue policy) |
| Increase button | "+" icon, same size. Disabled at maximum |
| Disabled state | Reduced opacity, not-allowed cursor |
| Labels | Input labeled "Quantity", buttons labeled "Decrease quantity" / "Increase quantity" |

#### Add to Cart Button

| Attribute | Description |
|---|---|
| Width | Full width |
| Style | Primary button: blue background, white text, semi-bold, generous padding, rounded |
| Text | "Add to cart" |
| Loading state | Text changes to "Adding..." with spinner icon. Button disabled during loading |
| Sold out state | Gray background, muted text, not-allowed cursor. Text: "Sold out" |
| Spacing | Generous top margin |

**Livewire action (addToCart):**

1. Validates variant is selected and in stock
2. Adds to cart (creates cart if none exists in session)
3. Dispatches "cart-updated" browser event
4. Cart drawer opens automatically

#### Product Description

- Separated from above content by a horizontal rule with generous vertical spacing
- Rendered HTML styled with prose/typography styling

#### Tags

- Displayed below the description (if tags exist)
- Horizontal wrapping row with gap
- Each tag: rounded pill, gray background, small text

### 5.4 Accessibility Summary

| Requirement | Implementation |
|---|---|
| Variant radio buttons | Properly labeled with labels and grouped with fieldset/legend |
| Live regions | Price display, stock messaging, add-to-cart feedback |
| Image alt text | All images have meaningful alt text |
| Keyboard | Tab through thumbnails, variant options, quantity controls, and add-to-cart |
| Focus indicators | Visible ring styles on all interactive elements |

---

## 6. Cart Drawer

**Livewire Component:** CartDrawer

### 6.1 Trigger and Animation

| Attribute | Description |
|---|---|
| Trigger | "cart-updated" browser event (from add-to-cart actions), or clicking the cart icon in the header |
| Animation | Slides in from the right side of the viewport. Transforms from fully off-screen to visible |
| Backdrop | Fixed full-screen semi-transparent dark overlay. Click to close |
| Panel | Fixed, right-aligned, full height, full width on small screens or 384px on larger screens. White background (dark mode: near-black), shadow |
| Body | Scroll locked when drawer is open |

### 6.2 Drawer Header


command: rg -n "Phase 3|Phase 4|Cart Service|Checkout|Discount|Shipping|Tax" specs/09-IMPLEMENTATION-ROADMAP.md specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/04-STOREFRONT-UI.md

type: search

cmd: rg -n 'Phase 3|Phase 4|Cart Service|Checkout|Discount|Shipping|Tax' specs/09-IMPLEMENTATION-ROADMAP.md specs/05-BUSINESS-LOGIC.md specs/01-DATABASE-SCHEMA.md specs/04-STOREFRONT-UI.md

query: Phase 3|Phase 4|Cart Service|Checkout|Discount|Shipping|Tax

aggregated_output:
specs/01-DATABASE-SCHEMA.md:526:## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
specs/01-DATABASE-SCHEMA.md:565:| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
specs/01-DATABASE-SCHEMA.md:579:Checkout session progressing through address, shipping, payment steps.
specs/01-DATABASE-SCHEMA.md:634:Shipping rate options within a zone.
specs/01-DATABASE-SCHEMA.md:665:| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
specs/01-DATABASE-SCHEMA.md:674:Discount codes and automatic discounts.
specs/01-DATABASE-SCHEMA.md:681:| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |
specs/01-DATABASE-SCHEMA.md:682:| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |
specs/01-DATABASE-SCHEMA.md:764:| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
specs/01-DATABASE-SCHEMA.md:768:| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |
specs/01-DATABASE-SCHEMA.md:814:**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.
specs/01-DATABASE-SCHEMA.md:956:| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |
specs/01-DATABASE-SCHEMA.md:957:| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |
specs/01-DATABASE-SCHEMA.md:1367:### Epics 5-6 - Cart, Checkout, Orders, and Payments
specs/01-DATABASE-SCHEMA.md:1677:| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |
specs/04-STOREFRONT-UI.md:53:| Checkout | Purchase flow | checkout index, order confirmation |
specs/04-STOREFRONT-UI.md:801:### 6.4 Discount Code Section
specs/04-STOREFRONT-UI.md:809:| [Discount code input] [Apply]        |
specs/04-STOREFRONT-UI.md:813:**Discount applied:**
specs/04-STOREFRONT-UI.md:823:| Input | Small text, placeholder "Discount code" |
specs/04-STOREFRONT-UI.md:834:| Discount (SUMMER20)          -$22.50 |
specs/04-STOREFRONT-UI.md:844:| Discount line | Only shown when discount applied, green text |
specs/04-STOREFRONT-UI.md:846:| Note | "Shipping and taxes calculated at checkout" in extra-small muted text below totals |
specs/04-STOREFRONT-UI.md:852:| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |
specs/04-STOREFRONT-UI.md:907:|                              [Discount code] [Apply]          |
specs/04-STOREFRONT-UI.md:908:|                                            Discount: -$12.75  |
specs/04-STOREFRONT-UI.md:911:|                                            [Checkout button]  |
specs/04-STOREFRONT-UI.md:929:- "Checkout" button: primary, aligned right
specs/04-STOREFRONT-UI.md:938:## 8. Checkout Page (GET /checkout/{checkoutId})
specs/04-STOREFRONT-UI.md:940:**Livewire Component:** CheckoutPage
specs/04-STOREFRONT-UI.md:951:|  [Checkout Form Sections]     | [Order Summary]   |
specs/04-STOREFRONT-UI.md:960:|  [Checkout Form Sections]                                     |
specs/04-STOREFRONT-UI.md:964:### 8.2 Checkout Steps
specs/04-STOREFRONT-UI.md:966:The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.
specs/04-STOREFRONT-UI.md:986:#### Step 2 - Shipping Address
specs/04-STOREFRONT-UI.md:1021:#### Step 3 - Shipping Method
specs/04-STOREFRONT-UI.md:1027:| (o) Standard Shipping          5-7 business days       $7.99 |
specs/04-STOREFRONT-UI.md:1028:| ( ) Express Shipping           2-3 business days      $14.99 |
specs/04-STOREFRONT-UI.md:1029:| ( ) Free Shipping              7-10 business days      Free  |
specs/04-STOREFRONT-UI.md:1128:|  [Discount code] [Apply]     |
specs/04-STOREFRONT-UI.md:1131:|  Discount             -$12.75|
specs/04-STOREFRONT-UI.md:1132:|  Shipping               $7.99|
specs/04-STOREFRONT-UI.md:1133:|  Tax                    $5.74|
specs/04-STOREFRONT-UI.md:1143:| Discount code input | Same as cart drawer |
specs/04-STOREFRONT-UI.md:1155:| Shipping radios | Grouped in a fieldset with legend |
specs/04-STOREFRONT-UI.md:1176:|  Shipping Address          |  Payment Method                  |
specs/04-STOREFRONT-UI.md:1182:|  Shipping         $7.99                                       |
specs/04-STOREFRONT-UI.md:1183:|  Tax              $5.74                                       |
specs/04-STOREFRONT-UI.md:1353:|  Shipping Address    |  Billing Address   | Payment           |
specs/04-STOREFRONT-UI.md:1358:|  Shipping         $7.99                                       |
specs/04-STOREFRONT-UI.md:1359:|  Tax              $5.74               …22868 chars truncated…t again | no duplicate order created, same order returned | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1324:#### tests/Feature/Checkout/CheckoutStateTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1340:#### tests/Feature/Checkout/PricingIntegrationTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1352:#### tests/Feature/Checkout/DiscountTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1365:#### tests/Feature/Checkout/ShippingTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1377:#### tests/Feature/Checkout/TaxTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1540:#### tests/Feature/Admin/DiscountManagementTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1648:#### tests/Feature/Api/StorefrontCheckoutApiTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1717:| `Checkout` | Checkout session entity |
specs/09-IMPLEMENTATION-ROADMAP.md:1718:| `ShippingZone` | Shipping zone configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:1719:| `ShippingRate` | Shipping rate within a zone |
specs/09-IMPLEMENTATION-ROADMAP.md:1720:| `TaxSettings` | Per-store tax configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:1721:| `Discount` | Discount code or automatic discount |
specs/09-IMPLEMENTATION-ROADMAP.md:1757:| `Admin/Discounts/Index` | Discount list |
specs/09-IMPLEMENTATION-ROADMAP.md:1758:| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |
specs/09-IMPLEMENTATION-ROADMAP.md:1760:| `Admin/Settings/Shipping` | Shipping zone/rate management |
specs/09-IMPLEMENTATION-ROADMAP.md:1761:| `Admin/Settings/Taxes` | Tax settings |
specs/09-IMPLEMENTATION-ROADMAP.md:1787:| `Storefront/Checkout/Show` | Multi-step checkout |
specs/09-IMPLEMENTATION-ROADMAP.md:1788:| `Storefront/Checkout/Confirmation` | Order confirmation |
specs/09-IMPLEMENTATION-ROADMAP.md:1811:| `DiscountPolicy` | Discount |
specs/09-IMPLEMENTATION-ROADMAP.md:1827:| `StoreDiscountRequest` | Validate discount creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1828:| `UpdateDiscountRequest` | Validate discount update |
specs/09-IMPLEMENTATION-ROADMAP.md:1829:| `SetCheckoutAddressRequest` | Validate checkout address |
specs/09-IMPLEMENTATION-ROADMAP.md:1830:| `SetCheckoutShippingRequest` | Validate checkout shipping selection |
specs/09-IMPLEMENTATION-ROADMAP.md:1836:| `StoreShippingZoneRequest` | Validate shipping zone creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1837:| `StoreShippingRateRequest` | Validate shipping rate creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1838:| `UpdateTaxSettingsRequest` | Validate tax settings update |
specs/09-IMPLEMENTATION-ROADMAP.md:1850:| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:1861:| `CheckoutCompleted` | Dispatched when checkout completes |
specs/09-IMPLEMENTATION-ROADMAP.md:1862:| `CheckoutAddressed` | Dispatched when address is set |
specs/09-IMPLEMENTATION-ROADMAP.md:1863:| `CheckoutShippingSelected` | Dispatched when shipping is selected |
specs/09-IMPLEMENTATION-ROADMAP.md:1874:| `DiscountCalculatorTest` | Discount validation and calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1875:| `TaxCalculatorTest` | Tax calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1876:| `ShippingCalculatorTest` | Shipping rate calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1896:| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1897:| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1898:| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1899:| `Checkout/DiscountTest` | Checkout discount feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1900:| `Checkout/ShippingTest` | Checkout shipping feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1901:| `Checkout/TaxTest` | Checkout tax feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1913:| `Admin/DiscountManagementTest` | Admin discount management feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1922:| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1953:| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |
specs/09-IMPLEMENTATION-ROADMAP.md:1968:| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |
specs/09-IMPLEMENTATION-ROADMAP.md:1970:| `DiscountService` | Discount validation, amount calculation, line allocation |
specs/09-IMPLEMENTATION-ROADMAP.md:1971:| `ShippingCalculator` | Zone matching, rate calculation |
specs/09-IMPLEMENTATION-ROADMAP.md:1972:| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |
specs/09-IMPLEMENTATION-ROADMAP.md:2052:7. `TaxSettingsSeeder`
specs/09-IMPLEMENTATION-ROADMAP.md:2053:8. `ShippingSeeder` - creates shipping zones and rates
specs/09-IMPLEMENTATION-ROADMAP.md:2056:11. `DiscountSeeder`


output:
Chunk ID: 025193
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 8217
Output:
specs/01-DATABASE-SCHEMA.md:526:## Epic 5 - Cart, Checkout, Discounts, Shipping, and Taxes
specs/01-DATABASE-SCHEMA.md:565:| line_discount_amount | INTEGER | No | 0 | - | Discount applied to this line |
specs/01-DATABASE-SCHEMA.md:579:Checkout session progressing through address, shipping, payment steps.
specs/01-DATABASE-SCHEMA.md:634:Shipping rate options within a zone.
specs/01-DATABASE-SCHEMA.md:665:| mode | TEXT | No | 'manual' | CHECK IN ('manual', 'provider') | Tax calculation mode |
specs/01-DATABASE-SCHEMA.md:674:Discount codes and automatic discounts.
specs/01-DATABASE-SCHEMA.md:681:| code | TEXT | Yes | NULL | UNIQUE per (store_id, code) | Discount code (null for automatic) |
specs/01-DATABASE-SCHEMA.md:682:| value_type | TEXT | No | - | CHECK IN ('fixed', 'percent', 'free_shipping') | Discount calculation type |
specs/01-DATABASE-SCHEMA.md:764:| fulfillment_status | TEXT | No | 'unfulfilled' | CHECK IN ('unfulfilled', 'partial', 'fulfilled') | Shipping status |
specs/01-DATABASE-SCHEMA.md:768:| shipping_amount | INTEGER | No | 0 | - | Shipping cost in minor units |
specs/01-DATABASE-SCHEMA.md:814:**Notes:** `product_id` and `variant_id` are nullable with SET NULL on delete so that order history survives product deletion. `tax_lines_json` example: `[{"title": "State Tax", "rate": 800, "amount": 400}]` (rate in basis points, e.g. 800 = 8%). `discount_allocations_json` example: `[{"discount_id": 5, "amount": 250}]`.
specs/01-DATABASE-SCHEMA.md:956:| checkout_started_count | INTEGER | No | 0 | - | Checkout-started events |
specs/01-DATABASE-SCHEMA.md:957:| checkout_completed_count | INTEGER | No | 0 | - | Checkout-completed events |
specs/01-DATABASE-SCHEMA.md:1367:### Epics 5-6 - Cart, Checkout, Orders, and Payments
specs/01-DATABASE-SCHEMA.md:1677:| 5 - Cart, Checkout, Discounts, Shipping, Taxes | carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts | 7 |
specs/04-STOREFRONT-UI.md:53:| Checkout | Purchase flow | checkout index, order confirmation |
specs/04-STOREFRONT-UI.md:801:### 6.4 Discount Code Section
specs/04-STOREFRONT-UI.md:809:| [Discount code input] [Apply]        |
specs/04-STOREFRONT-UI.md:813:**Discount applied:**
specs/04-STOREFRONT-UI.md:823:| Input | Small text, placeholder "Discount code" |
specs/04-STOREFRONT-UI.md:834:| Discount (SUMMER20)          -$22.50 |
specs/04-STOREFRONT-UI.md:844:| Discount line | Only shown when discount applied, green text |
specs/04-STOREFRONT-UI.md:846:| Note | "Shipping and taxes calculated at checkout" in extra-small muted text below totals |
specs/04-STOREFRONT-UI.md:852:| Checkout | Primary, full-width. Creates checkout record, then navigates to checkout page |
specs/04-STOREFRONT-UI.md:907:|                              [Discount code] [Apply]          |
specs/04-STOREFRONT-UI.md:908:|                                            Discount: -$12.75  |
specs/04-STOREFRONT-UI.md:911:|                                            [Checkout button]  |
specs/04-STOREFRONT-UI.md:929:- "Checkout" button: primary, aligned right
specs/04-STOREFRONT-UI.md:938:## 8. Checkout Page (GET /checkout/{checkoutId})
specs/04-STOREFRONT-UI.md:940:**Livewire Component:** CheckoutPage
specs/04-STOREFRONT-UI.md:951:|  [Checkout Form Sections]     | [Order Summary]   |
specs/04-STOREFRONT-UI.md:960:|  [Checkout Form Sections]                                     |
specs/04-STOREFRONT-UI.md:964:### 8.2 Checkout Steps
specs/04-STOREFRONT-UI.md:966:The checkout uses a single-page design with collapsible sections. Steps: Contact, Shipping Address, Shipping Method, Payment Method & Pay.
specs/04-STOREFRONT-UI.md:986:#### Step 2 - Shipping Address
specs/04-STOREFRONT-UI.md:1021:#### Step 3 - Shipping Method
specs/04-STOREFRONT-UI.md:1027:| (o) Standard Shipping          5-7 business days       $7.99 |
specs/04-STOREFRONT-UI.md:1028:| ( ) Express Shipping           2-3 business days      $14.99 |
specs/04-STOREFRONT-UI.md:1029:| ( ) Free Shipping              7-10 business days      Free  |
specs/04-STOREFRONT-UI.md:1128:|  [Discount code] [Apply]     |
specs/04-STOREFRONT-UI.md:1131:|  Discount             -$12.75|
specs/04-STOREFRONT-UI.md:1132:|  Shipping               $7.99|
specs/04-STOREFRONT-UI.md:1133:|  Tax                    $5.74|
specs/04-STOREFRONT-UI.md:1143:| Discount code input | Same as cart drawer |
specs/04-STOREFRONT-UI.md:1155:| Shipping radios | Grouped in a fieldset with legend |
specs/04-STOREFRONT-UI.md:1176:|  Shipping Address          |  Payment Method                  |
specs/04-STOREFRONT-UI.md:1182:|  Shipping         $7.99                                       |
specs/04-STOREFRONT-UI.md:1183:|  Tax              $5.74                                       |
specs/04-STOREFRONT-UI.md:1353:|  Shipping Address    |  Billing Address   | Payment           |
specs/04-STOREFRONT-UI.md:1358:|  Shipping         $7.99                                       |
specs/04-STOREFRONT-UI.md:1359:|  Tax              $5.74                                       |
specs/04-STOREFRONT-UI.md:1360:|  Discount        -$0.00                                       |
specs/04-STOREFRONT-UI.md:1545:| Checkout | Single column, summary collapsed | Single column | Two columns (form + summary) |
specs/04-STOREFRONT-UI.md:1583:| Checkout | Form validation with describedby linking to errors, invalid state on fields. Focus management on step transitions. Shipping radios in fieldset/legend. Totals in live region |
specs/04-STOREFRONT-UI.md:1687:| checkout | Checkout model | Required | Checkout with cart, cart lines, and variants eager-loaded |
specs/04-STOREFRONT-UI.md:1688:| showDiscountInput | boolean | true | Whether to show the discount code input |
specs/04-STOREFRONT-UI.md:1733:| CheckoutPage | Checkout | Multi-step checkout flow |
specs/04-STOREFRONT-UI.md:1813:    K -->|Apply Discount| L[Enter Discount Code]
specs/04-STOREFRONT-UI.md:1815:    K -->|Checkout| M[Checkout Page]
specs/04-STOREFRONT-UI.md:1817:    N --> O[Step 2: Shipping Address]
specs/04-STOREFRONT-UI.md:1818:    O --> P[Step 3: Shipping Method]
specs/04-STOREFRONT-UI.md:1860:    C --> I[Apply Discount Code]
specs/04-STOREFRONT-UI.md:1862:    J -->|Yes| K[Discount Applied]
specs/04-STOREFRONT-UI.md:1866:    C --> M[Proceed to Checkout]
specs/04-STOREFRONT-UI.md:1867:    M --> N[Checkout Page]
specs/04-STOREFRONT-UI.md:1883:        HasItems --> Checkout: Proceed to checkout
specs/04-STOREFRONT-UI.md:1896:    state "Checkout" as CO {
specs/04-STOREFRONT-UI.md:1897:        Contact --> ShippingAddress: Complete step 1
specs/04-STOREFRONT-UI.md:1898:        ShippingAddress --> ShippingMethod: Complete step 2
specs/04-STOREFRONT-UI.md:1899:        ShippingMethod --> Payment: Complete step 3
specs/04-STOREFRONT-UI.md:1915:| Discount applied | "Discount applied" | success |
specs/05-BUSINESS-LOGIC.md:240:| **Reserve** | Checkout enters `payment_selected` | No change | +quantity | If policy=deny, verify available >= quantity. Throw `InsufficientInventoryException` if not. |
specs/05-BUSINESS-LOGIC.md:241:| **Release** | Checkout expires or is abandoned | No change | -quantity | None |
specs/05-BUSINESS-LOGIC.md:251:## 4. Cart Service
specs/05-BUSINESS-LOGIC.md:260:    active --> converted : Checkout completed successfully
specs/05-BUSINESS-LOGIC.md:360:    B --> C["Step 3: Discount
specs/05-BUSINESS-LOGIC.md:363:    C --> D["Step 4: Discounted Subtotal
specs/05-BUSINESS-LOGIC.md:365:    D --> E["Step 5: Shipping
specs/05-BUSINESS-LOGIC.md:367:    E --> F["Step 6: Tax
specs/05-BUSINESS-LOGIC.md:382:| `shipping` | integer (cents) | Shipping cost |
specs/05-BUSINESS-LOGIC.md:383:| `taxLines` | array of TaxLine | Individual tax line items |
specs/05-BUSINESS-LOGIC.md:388:**Value Object:** `App\ValueObjects\TaxLine`
specs/05-BUSINESS-LOGIC.md:392:| `name` | string | Tax label (e.g., "State Tax") |
specs/05-BUSINESS-LOGIC.md:393:| `rate` | integer | Tax rate in basis points (e.g., 800 = 8.00%) |
specs/05-BUSINESS-LOGIC.md:411:## 6. Checkout State Machine
specs/05-BUSINESS-LOGIC.md:415:**Enum:** `App\Enums\CheckoutStatus` - values: `started`, `addressed`, `shipping_selected`, `payment_selected`, `completed`, `expired`
specs/05-BUSINESS-LOGIC.md:419:    [*] --> started : Checkout created from cart
specs/05-BUSINESS-LOGIC.md:421:    addressed --> shipping_selected : Shipping method chosen
specs/05-BUSINESS-LOGIC.md:439:**Service:** `App\Services\CheckoutService`
specs/05-BUSINESS-LOGIC.md:464:1. Validate address fields using a dedicated Form Request (`SetCheckoutAddressRequest`).
specs/05-BUSINESS-LOGIC.md:469:6. Dispatch `CheckoutAddressed` event.
specs/05-BUSINESS-LOGIC.md:473:**Method:** `setShippingMethod(checkout, shippingRateId)`
specs/05-BUSINESS-LOGIC.md:485:6. Dispatch `CheckoutShippingSelected` event.
specs/05-BUSINESS-LOGIC.md:504:**Method:** `completeCheckout(checkout, paymentMethodData)` - returns an Order
specs/05-BUSINESS-LOGIC.md:513:PROCEDURE completeCheckout(checkout, paymentMethodData):
specs/05-BUSINESS-LOGIC.md:580:**Triggered by:** `App\Jobs\ExpireAbandonedCheckouts` scheduled job (every 15 minutes).
specs/05-BUSINESS-LOGIC.md:582:**Conditions:** Checkouts where status is NOT `completed` or `expired`, AND `updated_at` < now - 24 hours.
specs/05-BUSINESS-LOGIC.md:587:3. Dispatch `CheckoutExpired` event.
specs/05-BUSINESS-LOGIC.md:593:| `CheckoutAddressed` | Address saved on checkout |
specs/05-BUSINESS-LOGIC.md:594:| `CheckoutShippingSelected` | Shipping method selected |
specs/05-BUSINESS-LOGIC.md:595:| `CheckoutCompleted` | Payment confirmed and order created |
specs/05-BUSINESS-LOGIC.md:596:| `CheckoutExpired` | Checkout expired due to inactivity |
specs/05-BUSINESS-LOGIC.md:600:## 7. Discount Engine
specs/05-BUSINESS-LOGIC.md:602:### 7.1 Discount Types
specs/05-BUSINESS-LOGIC.md:604:**Enum:** `App\Enums\DiscountType` - values: `code`, `automatic`
specs/05-BUSINESS-LOGIC.md:615:### Discount Status Lifecycle
specs/05-BUSINESS-LOGIC.md:632:**Enum:** `App\Enums\DiscountValueType` - values: `percent`, `fixed`, `free_shipping`
specs/05-BUSINESS-LOGIC.md:647:| `applicable_product_ids` | array of integers or null | Discount only applies to lines with these product IDs. Null/empty means all products. |
specs/05-BUSINESS-LOGIC.md:648:| `applicable_collection_ids` | array of integers or null | Discount applies to products belonging to these collections. Null/empty means all. |
specs/05-BUSINESS-LOGIC.md:655:**Service:** `App\Services\DiscountService`
specs/05-BUSINESS-LOGIC.md:657:**Method:** `validate(code, cart)` - returns a DiscountValidationResult value object
specs/05-BUSINESS-LOGIC.md:690:### 7.6 Discount Allocation
specs/05-BUSINESS-LOGIC.md:695:PROCEDURE allocateDiscount(discount, cartLines):
specs/05-BUSINESS-LOGIC.md:701:        totalDiscount = qualifyingSubtotal * discount.value_amount / 100
specs/05-BUSINESS-LOGIC.md:703:        totalDiscount = MIN(discount.value_amount, qualifyingSubtotal)
specs/05-BUSINESS-LOGIC.md:705:    remainingDiscount = totalDiscount
specs/05-BUSINESS-LOGIC.md:708:        lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
specs/05-BUSINESS-LOGIC.md:709:        remainingDiscount -= lineDiscount
specs/05-BUSINESS-LOGIC.md:710:        SET line.line_discount_amount = lineDiscount
specs/05-BUSINESS-LOGIC.md:713:    SET lastLine.line_discount_amount = remainingDiscount
specs/05-BUSINESS-LOGIC.md:726:- This happens within the order creation transaction (see Section 6.2, completeCheckout procedure, step 7).
specs/05-BUSINESS-LOGIC.md:730:## 8. Tax Calculation
specs/05-BUSINESS-LOGIC.md:732:### 8.1 Tax Provider Architecture
specs/05-BUSINESS-LOGIC.md:734:**Interface:** `App\Contracts\TaxProvider`
specs/05-BUSINESS-LOGIC.md:736:- Must implement a `calculate(request)` method that accepts a TaxCalculationRequest and returns a TaxCalculationResult.
specs/05-BUSINESS-LOGIC.md:737:- The TaxCalculationRequest contains: line items (with discounted amounts), shipping amount, shipping address, store tax settings.
specs/05-BUSINESS-LOGIC.md:738:- The TaxCalculationResult contains: array of TaxLine value objects, total tax amount.
specs/05-BUSINESS-LOGIC.md:740:**Service:** `App\Services\TaxCalculator` - orchestrates provider selection and delegates to the appropriate implementation.
specs/05-BUSINESS-LOGIC.md:743:- `App\Services\Tax\ManualTaxProvider` - default, uses manually configured rates
specs/05-BUSINESS-LOGIC.md:744:- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API integration
specs/05-BUSINESS-LOGIC.md:746:### 8.2 Manual Tax Mode
specs/05-BUSINESS-LOGIC.md:761:### 8.3 Prices Include Tax
specs/05-BUSINESS-LOGIC.md:765:#### Tax-inclusive pricing (prices_include_tax = true)
specs/05-BUSINESS-LOGIC.md:769:- Tax is "extracted" from the gross amount:
specs/05-BUSINESS-LOGIC.md:782:#### Tax-exclusive pricing (prices_include_tax = false)
specs/05-BUSINESS-LOGIC.md:785:- Tax added on top:
specs/05-BUSINESS-LOGIC.md:796:### 8.4 Stripe Tax Provider (Stub)
specs/05-BUSINESS-LOGIC.md:798:**Implementation:** `App\Services\Tax\StripeTaxProvider`
specs/05-BUSINESS-LOGIC.md:800:- Sends line items and shipping address to Stripe Tax API.
specs/05-BUSINESS-LOGIC.md:806:| `"block"` | Block checkout progression if Stripe Tax API errors |
specs/05-BUSINESS-LOGIC.md:809:### 8.5 Discount-Before-Tax Semantics
specs/05-BUSINESS-LOGIC.md:811:Discounts are always applied before tax calculation. Tax is calculated on the discounted amount. This aligns with standard accounting practice and Stripe Tax behavior.
specs/05-BUSINESS-LOGIC.md:813:For tax-inclusive pricing (`prices_include_tax = true`): The discount is subtracted from the gross (tax-inclusive) subtotal. Tax is then extracted from the post-discount gross amount: `tax = post_discount_gross * rate / (1 + rate)`. This means the tax portion decreases proportionally when a discount is applied.
specs/05-BUSINESS-LOGIC.md:823:## 9. Shipping Calculation
specs/05-BUSINESS-LOGIC.md:827:**Service:** `App\Services\ShippingCalculator`
specs/05-BUSINESS-LOGIC.md:911:### 9.3 Requires Shipping Check
specs/05-BUSINESS-LOGIC.md:918:- Shipping is calculated only for physical items' weight.
specs/05-BUSINESS-LOGIC.md:921:### 9.4 Free Shipping Discount Override
specs/05-BUSINESS-LOGIC.md:1060:### 11.1 Order Creation (from Completed Checkout)
specs/05-BUSINESS-LOGIC.md:1062:See Section 6.2 "Transition: payment_selected -> completed" for the full procedure. The order creation is the central action within the completeCheckout transaction.
specs/05-BUSINESS-LOGIC.md:1362:| `checkout.completed` | Checkout successfully completed |
specs/05-BUSINESS-LOGIC.md:1596:| `CheckoutAddressed` | Recalculate pricing |
specs/05-BUSINESS-LOGIC.md:1597:| `CheckoutShippingSelected` | Recalculate pricing |
specs/05-BUSINESS-LOGIC.md:1598:| `CheckoutCompleted` | Dispatch webhooks (`checkout.completed`) |
specs/05-BUSINESS-LOGIC.md:1599:| `CheckoutExpired` | Release reserved inventory |
specs/05-BUSINESS-LOGIC.md:1619:| `App\Jobs\ExpireAbandonedCheckouts` | Every 15 minutes | Expire checkouts idle for 24+ hours, release reserved inventory |
specs/05-BUSINESS-LOGIC.md:1643:| CheckoutService | `App\Services` | Checkout state machine transitions |
specs/05-BUSINESS-LOGIC.md:1644:| DiscountService | `App\Services` | Discount validation and application |
specs/05-BUSINESS-LOGIC.md:1645:| TaxCalculator | `App\Services` | Tax calculation orchestration (delegates to providers) |
specs/05-BUSINESS-LOGIC.md:1646:| ManualTaxProvider | `App\Services\Tax` | Manual tax rate calculation |
specs/05-BUSINESS-LOGIC.md:1647:| StripeTaxProvider | `App\Services\Tax` | Stripe Tax API integration (stub) |
specs/05-BUSINESS-LOGIC.md:1648:| ShippingCalculator | `App\Services` | Zone matching, rate calculation |
specs/05-BUSINESS-LOGIC.md:1664:| TaxProvider | `App\Contracts` | ManualTaxProvider, StripeTaxProvider |
specs/05-BUSINESS-LOGIC.md:1678:| DiscountType | Code (`code`), Automatic (`automatic`) |
specs/05-BUSINESS-LOGIC.md:1679:| DiscountValueType | Percent (`percent`), Fixed (`fixed`), FreeShipping (`free_shipping`) |
specs/05-BUSINESS-LOGIC.md:1681:| CheckoutStatus | Started (`started`), Addressed (`addressed`), ShippingSelected (`shipping_selected`), PaymentSelected (`payment_selected`), Completed (`completed`), Expired (`expired`) |
specs/05-BUSINESS-LOGIC.md:1688:| ShippingRateType | Flat (`flat`), Weight (`weight`), Price (`price`), Carrier (`carrier`) |
specs/05-BUSINESS-LOGIC.md:1700:| PricingResult | subtotal (int), discount (int), shipping (int), taxLines (array of TaxLine), taxTotal (int), total (int), currency (string) | Complete pricing calculation output |
specs/05-BUSINESS-LOGIC.md:1701:| TaxLine | name (string), rate (int, basis points), amount (int, cents) | Individual tax line item |
specs/05-BUSINESS-LOGIC.md:1702:| TaxCalculationRequest | lineItems (array), shippingAmount (int), address (Address), taxSettings (object) | Input for tax providers |
specs/05-BUSINESS-LOGIC.md:1703:| TaxCalculationResult | taxLines (array of TaxLine), totalAmount (int) | Output from tax providers |
specs/05-BUSINESS-LOGIC.md:1704:| DiscountValidationResult | valid (bool), discount (nullable), errorCode (nullable string), errorMessage (nullable string) | Discount validation outcome |
specs/05-BUSINESS-LOGIC.md:1709:| ShippingRate | id (int), name (string), amount (int, cents), type (string) | Calculated shipping rate option |
specs/09-IMPLEMENTATION-ROADMAP.md:43:    P3[Phase 3: Themes and Storefront Layout]
specs/09-IMPLEMENTATION-ROADMAP.md:44:    P4[Phase 4: Cart, Checkout, Discounts, Shipping, Taxes]
specs/09-IMPLEMENTATION-ROADMAP.md:213:| Policy | `DiscountPolicy` | Discount authorization based on store role |
specs/09-IMPLEMENTATION-ROADMAP.md:349:### Phase 3: Themes, Pages, Navigation, Storefront Layout
specs/09-IMPLEMENTATION-ROADMAP.md:442:### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes
specs/09-IMPLEMENTATION-ROADMAP.md:464:| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |
specs/09-IMPLEMENTATION-ROADMAP.md:465:| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:466:| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |
specs/09-IMPLEMENTATION-ROADMAP.md:467:| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:468:| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |
specs/09-IMPLEMENTATION-ROADMAP.md:474:| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |
specs/09-IMPLEMENTATION-ROADMAP.md:476:| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |
specs/09-IMPLEMENTATION-ROADMAP.md:477:| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |
specs/09-IMPLEMENTATION-ROADMAP.md:478:| `ShippingRate` | `belongsTo(ShippingZone)` |
specs/09-IMPLEMENTATION-ROADMAP.md:479:| `TaxSettings` | `belongsTo(Store)` |
specs/09-IMPLEMENTATION-ROADMAP.md:480:| `Discount` | `belongsTo(Store)` |
specs/09-IMPLEMENTATION-ROADMAP.md:487:| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |
specs/09-IMPLEMENTATION-ROADMAP.md:488:| `DiscountType` | `Code`, `Automatic` |
specs/09-IMPLEMENTATION-ROADMAP.md:489:| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |
specs/09-IMPLEMENTATION-ROADMAP.md:490:| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |
specs/09-IMPLEMENTATION-ROADMAP.md:491:| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |
specs/09-IMPLEMENTATION-ROADMAP.md:492:| `TaxMode` | `Manual`, `Provider` |
specs/09-IMPLEMENTATION-ROADMAP.md:494:#### Step 4.3: Cart Service
specs/09-IMPLEMENTATION-ROADMAP.md:509:#### Step 4.4: Discount Service
specs/09-IMPLEMENTATION-ROADMAP.md:511:**File to create:** `App\Services\DiscountService`
specs/09-IMPLEMENTATION-ROADMAP.md:515:| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |
specs/09-IMPLEMENTATION-ROADMAP.md:516:| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |
specs/09-IMPLEMENTATION-ROADMAP.md:518:Case-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.
specs/09-IMPLEMENTATION-ROADMAP.md:520:#### Step 4.5: Shipping Calculator
specs/09-IMPLEMENTATION-ROADMAP.md:522:**File to create:** `App\Services\ShippingCalculator`
specs/09-IMPLEMENTATION-ROADMAP.md:527:| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |
specs/09-IMPLEMENTATION-ROADMAP.md:531:#### Step 4.6: Tax Calculator
specs/09-IMPLEMENTATION-ROADMAP.md:533:**File to create:** `App\Services\TaxCalculator`
specs/09-IMPLEMENTATION-ROADMAP.md:537:| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |
specs/09-IMPLEMENTATION-ROADMAP.md:547:Method: `calculate(Checkout $checkout): PricingResult`
specs/09-IMPLEMENTATION-ROADMAP.md:552:3. Discount: apply rules, allocate proportionally
specs/09-IMPLEMENTATION-ROADMAP.md:553:4. Discounted subtotal: subtotal minus discount
specs/09-IMPLEMENTATION-ROADMAP.md:554:5. Shipping: based on selected method/rate
specs/09-IMPLEMENTATION-ROADMAP.md:555:6. Tax: on discounted amounts (plus shipping if taxable)
specs/09-IMPLEMENTATION-ROADMAP.md:565:| Value Object | `App\ValueObjects\TaxLine` | name, rate (basis points), amount (cents) |
specs/09-IMPLEMENTATION-ROADMAP.md:567:#### Step 4.8: Checkout State Machine
specs/09-IMPLEMENTATION-ROADMAP.md:569:**File to create:** `App\Services\CheckoutService`
specs/09-IMPLEMENTATION-ROADMAP.md:573:| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |
specs/09-IMPLEMENTATION-ROADMAP.md:574:| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |
specs/09-IMPLEMENTATION-ROADMAP.md:575:| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:576:| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |
specs/09-IMPLEMENTATION-ROADMAP.md:577:| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |
specs/09-IMPLEMENTATION-ROADMAP.md:583:| Job | `App\Jobs\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |
specs/09-IMPLEMENTATION-ROADMAP.md:586:#### Step 4.9: Storefront Cart/Checkout UI
specs/09-IMPLEMENTATION-ROADMAP.md:594:| `Storefront\Checkout\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |
specs/09-IMPLEMENTATION-ROADMAP.md:595:| `Storefront\Checkout\Confirmation` | Order confirmation: order number, items, totals, next steps |
specs/09-IMPLEMENTATION-ROADMAP.md:668:| `charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult` | Processes payment in-process. For credit_card: validates magic card numbers, returns success/decline. For paypal: always succeeds. For bank_transfer: returns pending (deferred capture). |
specs/09-IMPLEMENTATION-ROADMAP.md:702:| `createFromCheckout(Checkout $checkout): Order` | Atomic transaction: create order + order lines with snapshots, commit inventory, mark cart converted, dispatch `OrderCreated` event |
specs/09-IMPLEMENTATION-ROADMAP.md:810:> **Shared-form pattern:** Create and Edit routes render the same Livewire form component in create vs edit mode. Shared form components follow the pattern `App\Livewire\Admin\{Resource}\Form`, used for both create and edit routes as defined in Spec 03. This applies to Products, Collections, Discounts, and Pages.
specs/09-IMPLEMENTATION-ROADMAP.md:838:| `Admin\Discounts\Index` | Discount list with status/type filters |
specs/09-IMPLEMENTATION-ROADMAP.md:839:| `Admin\Discounts\Form` | Shared form component for create/edit discount: code, type, value, dates, rules |
specs/09-IMPLEMENTATION-ROADMAP.md:840:| `Admin\Settings\Index` | Tabbed settings: General, Domains, Shipping, Taxes, Checkout, Notifications |
specs/09-IMPLEMENTATION-ROADMAP.md:841:| `Admin\Settings\Shipping` | Shipping zones CRUD, rates CRUD per zone |
specs/09-IMPLEMENTATION-ROADMAP.md:842:| `Admin\Settings\Taxes` | Tax mode toggle, manual rate configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:989:| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:994:| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:998:| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:1002:| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |
specs/09-IMPLEMENTATION-ROADMAP.md:1034:#### tests/Unit/DiscountCalculatorTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1036:**Setup:** Instantiate `DiscountService` with mock store and cart.
specs/09-IMPLEMENTATION-ROADMAP.md:1040:| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |
specs/09-IMPLEMENTATION-ROADMAP.md:1041:| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1042:| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1043:| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1044:| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1046:| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |
specs/09-IMPLEMENTATION-ROADMAP.md:1056:#### tests/Unit/TaxCalculatorTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1058:**Setup:** Instantiate `TaxCalculator` directly.
specs/09-IMPLEMENTATION-ROADMAP.md:1072:#### tests/Unit/ShippingCalculatorTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1074:**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.
specs/09-IMPLEMENTATION-ROADMAP.md:1312:#### tests/Feature/Checkout/CheckoutFlowTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1319:| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1320:| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |
specs/09-IMPLEMENTATION-ROADMAP.md:1324:#### tests/Feature/Checkout/CheckoutStateTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1340:#### tests/Feature/Checkout/PricingIntegrationTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1352:#### tests/Feature/Checkout/DiscountTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1365:#### tests/Feature/Checkout/ShippingTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1377:#### tests/Feature/Checkout/TaxTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1540:#### tests/Feature/Admin/DiscountManagementTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1648:#### tests/Feature/Api/StorefrontCheckoutApiTest.php
specs/09-IMPLEMENTATION-ROADMAP.md:1717:| `Checkout` | Checkout session entity |
specs/09-IMPLEMENTATION-ROADMAP.md:1718:| `ShippingZone` | Shipping zone configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:1719:| `ShippingRate` | Shipping rate within a zone |
specs/09-IMPLEMENTATION-ROADMAP.md:1720:| `TaxSettings` | Per-store tax configuration |
specs/09-IMPLEMENTATION-ROADMAP.md:1721:| `Discount` | Discount code or automatic discount |
specs/09-IMPLEMENTATION-ROADMAP.md:1757:| `Admin/Discounts/Index` | Discount list |
specs/09-IMPLEMENTATION-ROADMAP.md:1758:| `Admin/Discounts/Form` | Shared discount form (create/edit mode) |
specs/09-IMPLEMENTATION-ROADMAP.md:1760:| `Admin/Settings/Shipping` | Shipping zone/rate management |
specs/09-IMPLEMENTATION-ROADMAP.md:1761:| `Admin/Settings/Taxes` | Tax settings |
specs/09-IMPLEMENTATION-ROADMAP.md:1787:| `Storefront/Checkout/Show` | Multi-step checkout |
specs/09-IMPLEMENTATION-ROADMAP.md:1788:| `Storefront/Checkout/Confirmation` | Order confirmation |
specs/09-IMPLEMENTATION-ROADMAP.md:1811:| `DiscountPolicy` | Discount |
specs/09-IMPLEMENTATION-ROADMAP.md:1827:| `StoreDiscountRequest` | Validate discount creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1828:| `UpdateDiscountRequest` | Validate discount update |
specs/09-IMPLEMENTATION-ROADMAP.md:1829:| `SetCheckoutAddressRequest` | Validate checkout address |
specs/09-IMPLEMENTATION-ROADMAP.md:1830:| `SetCheckoutShippingRequest` | Validate checkout shipping selection |
specs/09-IMPLEMENTATION-ROADMAP.md:1836:| `StoreShippingZoneRequest` | Validate shipping zone creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1837:| `StoreShippingRateRequest` | Validate shipping rate creation |
specs/09-IMPLEMENTATION-ROADMAP.md:1838:| `UpdateTaxSettingsRequest` | Validate tax settings update |
specs/09-IMPLEMENTATION-ROADMAP.md:1850:| `ExpireAbandonedCheckouts` | Expire timed-out checkouts, release inventory |
specs/09-IMPLEMENTATION-ROADMAP.md:1861:| `CheckoutCompleted` | Dispatched when checkout completes |
specs/09-IMPLEMENTATION-ROADMAP.md:1862:| `CheckoutAddressed` | Dispatched when address is set |
specs/09-IMPLEMENTATION-ROADMAP.md:1863:| `CheckoutShippingSelected` | Dispatched when shipping is selected |
specs/09-IMPLEMENTATION-ROADMAP.md:1874:| `DiscountCalculatorTest` | Discount validation and calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1875:| `TaxCalculatorTest` | Tax calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1876:| `ShippingCalculatorTest` | Shipping rate calculation unit tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1896:| `Checkout/CheckoutFlowTest` | Checkout flow feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1897:| `Checkout/CheckoutStateTest` | Checkout state machine feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1898:| `Checkout/PricingIntegrationTest` | Pricing integration feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1899:| `Checkout/DiscountTest` | Checkout discount feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1900:| `Checkout/ShippingTest` | Checkout shipping feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1901:| `Checkout/TaxTest` | Checkout tax feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1913:| `Admin/DiscountManagementTest` | Admin discount management feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1922:| `Api/StorefrontCheckoutApiTest` | Storefront checkout API feature tests |
specs/09-IMPLEMENTATION-ROADMAP.md:1953:| `routes/console.php` | Scheduled jobs: `CleanupAbandonedCarts` daily, `ExpireAbandonedCheckouts` every 15 minutes, `AggregateAnalytics` daily, `CancelUnpaidBankTransferOrders` daily |
specs/09-IMPLEMENTATION-ROADMAP.md:1968:| `CheckoutService` | State machine, transitions, address/shipping/payment coordination |
specs/09-IMPLEMENTATION-ROADMAP.md:1970:| `DiscountService` | Discount validation, amount calculation, line allocation |
specs/09-IMPLEMENTATION-ROADMAP.md:1971:| `ShippingCalculator` | Zone matching, rate calculation |
specs/09-IMPLEMENTATION-ROADMAP.md:1972:| `TaxCalculator` | Tax calculation (exclusive/inclusive), rate lookup |
specs/09-IMPLEMENTATION-ROADMAP.md:2052:7. `TaxSettingsSeeder`
specs/09-IMPLEMENTATION-ROADMAP.md:2053:8. `ShippingSeeder` - creates shipping zones and rates
specs/09-IMPLEMENTATION-ROADMAP.md:2056:11. `DiscountSeeder`


output:
Chunk ID: 76ecfb
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 3961
Output:
8. `create_collection_products_table` - composite PK: `collection_id` + `product_id`
9. `create_product_media_table` - FK: `product_id`

#### Step 2.2: Models with Relationships

**Files to create:**

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

**Relationship definitions:**

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

**Enums to create:**

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

#### Step 2.3: Product Service

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

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

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

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

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

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

#### Step 2.4: Inventory Service

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

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

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

#### Step 2.5: Media Upload

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

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

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

---

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

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

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

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

#### Step 3.2: Models

**Files to create:**

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

**Relationship definitions:**

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

**Enums to create:**

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

#### Step 3.3: Storefront Blade Layout

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

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

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

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

#### Step 3.4: Storefront Livewire Components

**Files to create:**

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

#### Step 3.5: Navigation Service

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

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

Caches per store with 5-minute TTL.

---

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

**Priority:** HIGH - core shopping flow.

#### Step 4.1: Migrations (Batch 4-5)

1. `create_carts_table` - FK: `store_id`, `customer_id`
2. `create_cart_lines_table` - FK: `cart_id`, `variant_id`
3. `create_checkouts_table` - FK: `store_id`, `cart_id`, `customer_id`
4. `create_shipping_zones_table` - FK: `store_id`
5. `create_shipping_rates_table` - FK: `zone_id`
6. `create_tax_settings_table` - PK: `store_id`
7. `create_discounts_table` - FK: `store_id`

#### Step 4.2: Models

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Model (with migration, factory, seeder) | `Cart` | Shopping cart entity |
| Model (with migration, factory, seeder) | `CartLine` | Individual cart line item |
| Model (with migration, factory, seeder) | `Checkout` | Checkout session entity |
| Model (with migration, factory, seeder) | `ShippingZone` | Shipping zone configuration |
| Model (with migration, factory, seeder) | `ShippingRate` | Shipping rate within a zone |
| Model (with migration, factory, seeder) | `TaxSettings` | Per-store tax configuration |
| Model (with migration, factory, seeder) | `Discount` | Discount code or automatic discount |

**Relationship definitions:**

| Model | Key Relationships |
|-------|-------------------|
| `Cart` | `belongsTo(Store)`, `belongsTo(Customer)`, `hasMany(CartLine)`, `hasMany(Checkout)` |
| `CartLine` | `belongsTo(Cart)`, `belongsTo(ProductVariant)` |
| `Checkout` | `belongsTo(Store)`, `belongsTo(Cart)`, `belongsTo(Customer)` |
| `ShippingZone` | `belongsTo(Store)`, `hasMany(ShippingRate)` |
| `ShippingRate` | `belongsTo(ShippingZone)` |
| `TaxSettings` | `belongsTo(Store)` |
| `Discount` | `belongsTo(Store)` |

**Enums to create:**

| Enum | Values |
|------|--------|
| `CartStatus` | `Active`, `Converted`, `Abandoned` |
| `CheckoutStatus` | `Started`, `Addressed`, `ShippingSelected`, `PaymentPending`, `Completed`, `Expired` |
| `DiscountType` | `Code`, `Automatic` |
| `DiscountValueType` | `Percent` (`percent`), `Fixed` (`fixed`), `FreeShipping` (`free_shipping`) -- PascalCase cases backed by lowercase string values stored in DB |
| `DiscountStatus` | `Draft`, `Active`, `Expired`, `Disabled` |
| `ShippingRateType` | `Flat`, `Weight`, `Price`, `Carrier` |
| `TaxMode` | `Manual`, `Provider` |

#### Step 4.3: Cart Service

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

| Method | Description |
|--------|-------------|
| `create(Store, ?Customer): Cart` | Creates cart with store currency, version 1 |
| `addLine(Cart, int $variantId, int $qty): CartLine` | Validates variant/product active, inventory, increments existing line or creates new |
| `updateLineQuantity(Cart, int $lineId, int $qty): CartLine` | Validates quantity, recalculates amounts |
| `removeLine(Cart, int $lineId): void` | Deletes line, increments version |
| `getOrCreateForSession(Store, ?Customer): Cart` | Session-based cart binding |
| `mergeOnLogin(Cart $guest, Cart $customer): Cart` | Merges guest cart into customer cart |

All mutations increment `cart_version`. API clients must send `expected_version` or receive 409 Conflict.

#### Step 4.4: Discount Service

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

| Method | Description |
|--------|-------------|
| `validate(string $code, Store $store, Cart $cart): Discount` | Validates code exists, is active, dates valid, usage limit not reached, minimum purchase met |
| `calculate(Discount $discount, int $subtotal, array $lines): DiscountResult` | Calculates amount, allocates proportionally across qualifying lines |

Case-insensitive code lookup. Throws `InvalidDiscountException` with specific reason codes.

#### Step 4.5: Shipping Calculator

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

| Method | Description |
|--------|-------------|
| `getAvailableRates(Store $store, array $address): Collection` | Matches zones by country/region, returns active rates |
| `calculate(ShippingRate $rate, Cart $cart): int` | Calculates cost based on rate type (flat/weight/price) |

Zone matching: checks `countries_json` for country code, then `regions_json` for region code.

#### Step 4.6: Tax Calculator

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

| Method | Description |
|--------|-------------|
| `calculate(int $amount, TaxSettings $settings, array $address): TaxResult` | Calculates tax per manual rate or provider |
| `extractInclusive(int $grossAmount, int $rateBasisPoints): int` | Extracts tax from gross (prices-include-tax mode) |
| `addExclusive(int $netAmount, int $rateBasisPoints): int` | Adds tax to net amount |

All math uses integers. Rates stored in basis points (1900 = 19%).

#### Step 4.7: Pricing Engine

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

Method: `calculate(Checkout $checkout): PricingResult`

**Pipeline steps:**
1. Line subtotals: `unit_price * quantity` per line
2. Cart subtotal: sum of line subtotals
3. Discount: apply rules, allocate proportionally
4. Discounted subtotal: subtotal minus discount
5. Shipping: based on selected method/rate
6. Tax: on discounted amounts (plus shipping if taxable)
7. Total: discounted_subtotal + shipping + tax_total

Result stored in `checkouts.totals_json` as snapshot.

**Value objects to create:**

| Type | Name | Fields |
|------|------|--------|
| Value Object | `App\ValueObjects\PricingResult` | subtotal, discount, shipping, taxLines, taxTotal, total, currency |
| Value Object | `App\ValueObjects\TaxLine` | name, rate (basis points), amount (cents) |

#### Step 4.8: Checkout State Machine

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

| Transition | Method | Requirements |
|------------|--------|-------------|
| `started` -> `addressed` | `setAddress(Checkout, array)` | Valid email, shipping address fields |
| `addressed` -> `shipping_selected` | `setShippingMethod(Checkout, int $rateId)` | Rate belongs to applicable zone |
| `shipping_selected` -> `payment_selected` | `selectPaymentMethod(Checkout, string)` | All data valid, reserve inventory |
| `payment_selected` -> `completed` | `completeCheckout(Checkout, array)` | Payment confirmed, idempotent |
| Any active -> `expired` | `expireCheckout(Checkout)` | Via scheduled job |

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Job | `App\Jobs\ExpireAbandonedCheckouts` | Runs every 15 minutes via `routes/console.php`; finds checkouts with `expires_at < now()` and active status; releases reserved inventory, transitions to `expired` |
| Job | `App\Jobs\CleanupAbandonedCarts` | Runs daily; marks carts with `status = active` and `updated_at < 14 days ago` as `abandoned` |

#### Step 4.9: Storefront Cart/Checkout UI

**Files to create:**

| Component | Description |
|-----------|-------------|
| `Storefront\CartDrawer` | Slide-out panel: line items, quantity controls, discount code input, subtotal, checkout button |
| `Storefront\Cart\Show` | Full cart page with same functionality plus shipping estimate |
| `Storefront\Checkout\Show` | Multi-step: Contact/Address -> Shipping method -> Payment. Stepper UI. |
| `Storefront\Checkout\Confirmation` | Order confirmation: order number, items, totals, next steps |

---

### Phase 5: Payments, Orders, Fulfillment

**Priority:** HIGH - completes the purchase flow.

#### Step 5.1: Migrations (Batch 5-7)

1. `create_customers_table` - FK: `store_id`
2. `create_customer_addresses_table` - FK: `customer_id`
3. `create_orders_table` - FK: `store_id`, `customer_id`
4. `create_order_lines_table` - FK: `order_id`, `product_id` (nullable), `variant_id` (nullable)
5. `create_payments_table` - FK: `order_id`
6. `create_refunds_table` - FK: `order_id`, `payment_id`
7. `create_fulfillments_table` - FK: `order_id`
8. `create_fulfillment_lines_table` - FK: `fulfillment_id`, `order_line_id`

#### Step 5.2: Models

**Files to create:**

| Type | Name | Purpose |
|------|------|---------|
| Model (with migration, factory, seeder) | `Customer` | Store customer entity |