text:
I found the database schema spec. Here are the complete Phase 6 (Payments, Orders, Fulfillment, Customers) table definitions extracted from the spec:

---

## Phase 6 Table Definitions

### customers

Storefront customer accounts, scoped to a store.

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

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

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

---

### customer_addresses

Saved addresses for a customer.

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

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

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

---

### orders

Placed orders with financial and fulfillment tracking.

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

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

---

### order_lines

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

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

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

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

---

### payments

Payment attempts and captures for an order.

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

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

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

---

### refunds

Refund records linked to an order and payment.

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

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

---

### fulfillments

Shipment tracking for order fulfillment.

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

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

---

### fulfillment_lines

Links specific order lines and quantities to a fulfillment.

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

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

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

text:
agentId: a88e8b3 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 55761
tool_uses: 1
duration_ms: 23435</usage>

status: completed

prompt:
Read the database schema spec at /Users/fabianwesner/Herd/shop/specs/01-DATABASE-SCHEMA.md and extract ONLY the table definitions for the following tables that belong to Phase 5 (Payments, Orders, Fulfillment):
- customers
- customer_addresses
- orders
- order_lines
- payments
- refunds
- fulfillments
- fulfillment_lines

Return the complete table definitions including columns, types, constraints, indexes, and any notes.


teamName: wild-sleeping-shell

userType: external

slug: iterative-seeking-pine

type: user

role: user

type: text

text:
I have successfully read the complete business logic specification. Here is the comprehensive extraction of ALL business logic rules and details for the requested areas:

---

# BUSINESS LOGIC COMPLETE SPECIFICATION EXTRACT

## 1. CART OPERATIONS

### 1.1 Cart Versioning (Optimistic Concurrency)
- Every cart mutation increments `cart_version` by 1
- For API clients: client sends `expected_version` with mutation requests
- If current version != expected version: return HTTP 409 Conflict with current cart state
- Prevents race conditions across browser tabs/devices

### 1.2 Cart Lifecycle & Session Binding
```
[*] --> active (Cart created)
active --> converted (Checkout completed)
active --> abandoned (14 days inactive via job)
```

- **Guest users**: cart ID stored in session cookie with key `cart_id`
- **Authenticated customers**: cart linked via `customer_id` foreign key
- On first add-to-cart: cart created and ID stored in `session('cart_id')`

### 1.3 Cart Merge on Login
When guest visits storefront and later logs in:
```
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
```

### 1.4 Add Line Validations (in order)
1. Variant exists and belongs to current store (through product)
2. Product status = `active`
3. Variant status = `active`
4. Inventory policy check: if policy=`deny`, verify available >= requested
5. If variant already in cart: increment existing line quantity (no duplicate)
6. Set `unit_price_amount` from 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`

### 1.5 Update Quantity
- Validate new quantity > 0 (if 0, remove line instead)
- Re-check inventory policy for new quantity
- Recalculate line amounts
- Increment `cart_version`

### 1.6 Cart Expiration
- **Job**: `App\Jobs\CleanupAbandonedCarts` (daily)
- Finds: status = `active` AND updated_at < (now - 14 days)
- Actions: marks as `abandoned`, releases reserved inventory for active checkouts

---

## 2. CHECKOUT STATE MACHINE

### 2.1 States
```
[*] --> started (Checkout created from cart)
started --> addressed (Address provided)
addressed --> shipping_selected (Shipping method chosen) [pricing recalculation]
shipping_selected --> payment_selected (Payment method selected)
payment_selected --> completed (Payment confirmed) [order created, inventory committed]
payment_selected --> expired (Timeout 24h)
started --> expired (Timeout 24h)
addressed --> expired (Timeout 24h)
shipping_selected --> expired (Timeout 24h)
```

### 2.2 Transition: started -> addressed

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

**Required fields**:
- `email` - valid email address
- `shipping_address`:
  - Required: `first_name`, `last_name`, `address1`, `city`, `country` (2-letter ISO), `postal_code`
  - Optional: `address2`, `company`, `province`, `province_code`, `phone`

**Actions**:
1. Validate address fields using SetCheckoutAddressRequest
2. Save email and shipping_address_json on checkout
3. Copy shipping address to billing_address_json (default, can override)
4. **Recalculate pricing** (shipping zones and tax regions may change)
5. Transition status to `addressed`
6. Dispatch `CheckoutAddressed` event

### 2.3 Transition: addressed -> shipping_selected

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

**Skip condition**: If NO cart lines have `requires_shipping = true`, skip this entirely. Set status to `shipping_selected` with `shipping_method_id = null` and `shipping_amount = 0`

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

### 2.4 Transition: shipping_selected -> payment_selected

**Method**: `selectPaymentMethod(checkout, paymentMethod)`

**Requirements**:
- All previous data valid (address, shipping method)
- Totals calculated and stored
- `paymentMethod` = one of: `credit_card`, `paypal`, `bank_transfer`

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

### 2.5 Transition: payment_selected -> completed

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

**CRITICAL**: Must be **idempotent**. If called multiple times, must not create duplicate orders. Always check if order already exists for this checkout first.

**Full procedure (single transaction)**:
```
1. IF order already exists:
     RETURN existing order (idempotent)

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

3. Generate next sequential order number for store

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

5. CREATE order with checkout data (addresses, email, totals snapshot)
   - payment_method from checkout
   - status, financial_status, fulfillment_status per above
   - placed_at = now

6. FOR EACH cart line:
   CREATE order_line with snapshot:
   - title_snapshot (product + variant option labels)
   - sku_snapshot (variant SKU at purchase time)
   - 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
   - status = paymentStatus, amount = order total

8. IF inventoryAction = 'commit':
   FOR EACH cart line:
     CALL InventoryService.commit(variant, quantity)

9. IF checkout has discount code:
   INCREMENT discount.usage_count += 1

10. SET cart.status = 'converted'

11. SET checkout.status = 'completed'

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

13. DISPATCH OrderCreated event

COMMIT
RETURN order
```

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

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

**Conditions**: status NOT IN (`completed`, `expired`) AND `updated_at < now - 24 hours`

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

### 2.7 Checkout Events
| Event | When |
|---|---|
| CheckoutAddressed | Address saved |
| CheckoutShippingSelected | Shipping method selected |
| CheckoutCompleted | Payment confirmed, order created |
| CheckoutExpired | Inactivity timeout |

---

## 3. PRICING ENGINE

### 3.1 Deterministic Pipeline (Sequential)

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

Same inputs ALWAYS produce same output.

```
Step 1: Line Subtotals
  FOR EACH line: subtotal = unit_price * quantity

Step 2: Cart Subtotal
  SUM of all line subtotals

Step 3: Discount
  Apply discount rules (Section 7)
  Allocate proportionally across qualifying lines

Step 4: Discounted Subtotal
  subtotal - total_discount

Step 5: Shipping
  Based on selected method and rate rules (Section 9)

Step 6: Tax
  Calculated on discounted line amounts
  + shipping if taxable (Section 8)

Step 7: Total
  discounted_subtotal + shipping + tax_total
```

### 3.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 amounts |
| `total` | integer (cents) | Final: discounted_subtotal + shipping + taxTotal |
| `currency` | string | ISO 4217 code |

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

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

### 3.3 Rounding Strategy
- Each line's discount allocation, tax, total rounded independently
- Totals computed by summing already-rounded amounts
- Prevents accumulation of fractional cents across lines
- **Remainder from proportional allocation**: added to last qualifying line (largest-remainder method)

### 3.4 Snapshotting
- After calculation: store full result in `checkouts.totals_json` as JSON snapshot
- **Recalculate on**:
  - Address change (may affect tax/shipping zones)
  - Shipping method change
  - Discount code change

### 3.5 Monetary Convention
- **All amounts**: INTEGER in minor units (cents)
- **All arithmetic**: integers only, NEVER floating point
- **Percentage storage**:
  - Basis points where noted (1900 = 19.00%)
  - OR whole-number percentage in `discounts.value_amount` (15 = 15%)

---

## 4. DISCOUNT SYSTEM

### 4.1 Discount Types

**Enum**: `App\Enums\DiscountType`

| Type | Behavior |
|---|---|
| `code` | Customer enters code at checkout, case-insensitive, 1 per checkout |
| `automatic` | Applied automatically when conditions met, evaluated every pricing calc |

**Stacking**:
- Only ONE code discount per checkout
- Multiple automatic discounts CAN stack (each applied sequentially to remaining amount)

### 4.2 Discount Status Lifecycle

| Status | Description | Transition |
|---|---|---|
| `draft` | Created but inactive, not visible to customers | Created -> draft; Admin activates -> active |
| `active` | Live and applicable (subject to date/usage limits) | Automatic when starts_at reached; Admin activate |
| `expired` | Past ends_at date, no longer applicable | Automatic when ends_at passes (irreversible) |
| `disabled` | Manually deactivated, not applicable | Admin disables active -> disabled; Re-enable -> active |

**Allowed transitions**:
- draft -> active
- active -> disabled
- active -> expired (automatic)
- disabled -> active

### 4.3 Value Types

**Enum**: `App\Enums\DiscountValueType`

| Type | Calculation | Notes |
|---|---|---|
| `percent` | `discount = applicable_subtotal * value_amount / 100` | value_amount = whole percentage (15 = 15%) |
| `fixed` | `discount = value_amount` (cents) | Cannot exceed applicable subtotal |
| `free_shipping` | Override shipping to 0 | value_amount ignored |

### 4.4 Rules Storage

Rules stored in `discounts.rules_json`:

| Rule Key | Type | Description |
|---|---|---|
| `min_purchase_amount` | integer (cents) or null | Subtotal must be >= this. Null = no minimum |
| `applicable_product_ids` | array or null | Discount applies only to these products. Null/empty = all |
| `applicable_collection_ids` | array or null | Discount applies to products in these collections. Null/empty = all |
| `customer_eligibility` | string | "all" for all customers. Future: "returning", "specific" |

**Applicability logic**:
- When BOTH product IDs AND collection IDs set: product qualifies if matches **either** (UNION)
- When BOTH null/empty: discount applies to entire order

### 4.5 Validation Flow

**Service**: `App\Services\DiscountService`

**Method**: `validate(code, cart)` returns DiscountValidationResult

```
INPUT: discount code + cart

1. Code exists in store? (case-insensitive)
   NO -> Error: discount_not_found

2. Status = active?
   NO -> Error: discount_expired

3. starts_at <= now?
   NO -> Error: discount_not_yet_active

4. ends_at is null OR ends_at >= now?
   NO -> Error: discount_expired

5. usage_limit set AND usage_count >= usage_limit?
   YES -> Error: discount_usage_limit_reached

6. min_purchase_amount set AND subtotal < minimum?
   YES -> Error: discount_min_purchase_not_met

7. Product/collection restrictions set AND no cart lines qualify?
   YES -> Error: discount_not_applicable

8. Valid -> return discount
```

### 4.6 Error Codes

| Code | Description |
|---|---|
| `discount_not_found` | Code doesn't exist in store |
| `discount_expired` | Past ends_at OR status not active |
| `discount_not_yet_active` | Before starts_at |
| `discount_usage_limit_reached` | usage_count >= usage_limit |
| `discount_min_purchase_not_met` | Subtotal < min_purchase_amount |
| `discount_not_applicable` | No qualifying products |

### 4.7 Discount Allocation Algorithm

For `percent` and `fixed` with product restrictions:

```
PROCEDURE allocateDiscount(discount, cartLines):
  qualifyingLines = FILTER lines where:
    product IN applicable_product_ids
    OR product IN collection's products from applicable_collection_ids

  qualifyingSubtotal = SUM(qualifyingLines.line_subtotal_amount)

  IF value_type = 'percent':
    totalDiscount = qualifyingSubtotal * value_amount / 100
  ELSE IF value_type = 'fixed':
    totalDiscount = MIN(value_amount, qualifyingSubtotal)

  remainingDiscount = totalDiscount

  FOR EACH qualifyingLine (except last):
    lineDiscount = ROUND(totalDiscount * lineSubtotal / qualifyingSubtotal)
    remainingDiscount -= lineDiscount
    SET line.line_discount_amount = lineDiscount

  // Last line gets remainder (largest-remainder method)
  SET lastLine.line_discount_amount = remainingDiscount

  RECALCULATE line_total_amount for each affected line
```

### 4.8 Usage Tracking

When order successfully created with discount code:
- Increment `discounts.usage_count += 1`
- Within order creation transaction (Section 6.2, step 9)

---

## 5. SHIPPING CALCULATOR

### 5.1 Zone Matching Algorithm

**Service**: `App\Services\ShippingCalculator`

```
PROCEDURE getMatchingZone(address, store):
  zones = load all shipping zones for store
  bestMatch = null
  bestSpecificity = -1

  FOR EACH zone IN zones:
    countryMatch = address.country_code IN zone.countries_json
    regionMatch = address.province_code IN zone.regions_json

    IF countryMatch AND regionMatch:
      specificity = 2  // Most specific
    ELSE IF countryMatch:
      specificity = 1  // Country-only
    ELSE:
      CONTINUE  // No match

    IF specificity > bestSpecificity:
      bestMatch = zone
      bestSpecificity = specificity
    ELSE IF specificity = bestSpecificity AND zone.id < bestMatch.id:
      bestMatch = zone  // Tie-break: lowest ID wins

  RETURN bestMatch  // null if no match
```

**When no zone matches**: Address unserviceable. Block checkout with "Cannot ship to this address".

### 5.2 Rate Types

For matched zone, return all active rates (`is_active = 1`). Calculate cost for each:

#### 5.2.1 Flat Rate
- Config: `{"amount": 500}`
- Cost = fixed amount regardless of cart

#### 5.2.2 Weight-Based Rate
- Config: `{"ranges": [{"min_g": 0, "max_g": 1000, "amount": 500}, {"min_g": 1001, "max_g": 5000, "amount": 1000}]}`

```
PROCEDURE calculateWeightRate(config, cart):
  totalWeight = 0
  FOR EACH line IN cart.lines:
    IF line.variant.requires_shipping = true:
      totalWeight += line.variant.weight_g * line.quantity

  FOR EACH range IN config.ranges:
    IF range.min_g <= totalWeight AND totalWeight <= range.max_g:
      RETURN range.amount

  RETURN null  // Rate not available
```

#### 5.2.3 Price-Based Rate
- Config: `{"ranges": [{"min_amount": 0, "max_amount": 5000, "amount": 500}, {"min_amount": 5001, "amount": 0}]}`

```
PROCEDURE calculatePriceRate(config, cartSubtotal):
  FOR EACH range IN config.ranges:
    IF range.min_amount <= cartSubtotal:
      IF range.max_amount NOT SET OR cartSubtotal <= range.max_amount:
        RETURN range.amount

  RETURN null  // Not available
```

Range without `max_amount` matches all subtotals >= `min_amount` (e.g., "free shipping over X").

#### 5.2.4 Carrier-Calculated Rate (Stub)
- Config: `{"carrier": "ups", "service": "ground"}`
- Placeholder for future integration
- Returns hardcoded rate or error until implemented

### 5.3 Requires Shipping Check

- If NO line has `requires_shipping = true`: skip shipping entirely
- `shipping_amount = 0` in pricing result
- Checkout transitions directly past `shipping_selected` (status = `shipping_selected`, `shipping_method_id = null`)

**Mixed carts** (physical + digital):
- Calculate shipping only for physical items' weight
- Digital items excluded from weight calculations

### 5.4 Free Shipping Discount Override

If validated discount has `value_type = 'free_shipping'`:
- Override calculated shipping to 0
- Shipping method still selected (for fulfillment routing), but not charged

---

## 6. TAX CALCULATOR

### 6.1 Tax Provider Architecture

**Interface**: `App\Contracts\TaxProvider`

Must implement `calculate(request)`:
- Takes: TaxCalculationRequest (line items with discounted amounts, shipping, address, tax settings)
- Returns: TaxCalculationResult (array of TaxLine, total tax amount)

**Service**: `App\Services\TaxCalculator` - orchestrates provider selection

**Implementations**:
- `App\Services\Tax\ManualTaxProvider` - default, manually configured rates
- `App\Services\Tax\StripeTaxProvider` - stub for Stripe Tax API

### 6.2 Manual Tax Mode

**Rate resolution**:
1. Match shipping address against shipping zones (same algo as shipping)
2. Use tax rate from `tax_settings.config_json` for matched zone, or global default
3. Rate stored as integer basis points (1900 = 19.00%)

**Calculation per line**:
```
line_tax = ROUND(line_total_after_discount * rate / 10000)
```

Round each line independently, sum for total.

### 6.3 Prices Include Tax (Critical Feature)

Controlled by `tax_settings.prices_include_tax` (boolean).

#### 6.3.1 Tax-Inclusive Pricing (prices_include_tax = true)

Displayed prices = gross (include tax).

Tax is "extracted" from gross amount:
```
net_amount = intdiv(gross_amount * 10000, 10000 + rate_bps)
tax = gross_amount - net_amount
```

Uses **integer division** (truncation toward zero) via PHP `intdiv()` for deterministic extraction.

**Example**: Price = 1190 cents, rate = 1900 (19%)
```
net = intdiv(1190 * 10000, 11900) = intdiv(11900000, 11900) = 1000
tax = 1190 - 1000 = 190
```

#### 6.3.2 Tax-Exclusive Pricing (prices_include_tax = false)

Displayed prices = net (excluding tax).

Tax added on top:
```
tax = ROUND(net_amount * rate / 10000)
total = net_amount + tax
```

**Example**: Price = 1000 cents, rate = 1900
```
tax = ROUND(1000 * 1900 / 10000) = 190
total = 1190
```

### 6.4 Stripe Tax Provider (Stub)

**Implementation**: `App\Services\Tax\StripeTaxProvider`

- Sends line items and address to Stripe Tax API
- Stores full response in `checkouts.tax_provider_snapshot_json` (audit trail)
- Fallback behavior (configurable in `tax_settings.config_json`):

| Setting | Behavior |
|---|---|
| `"block"` | Block checkout if Stripe Tax API errors |
| `"allow"` | Allow checkout without tax (log warning) |

### 6.5 Discount-Before-Tax Semantics

**Discounts always applied BEFORE tax calculation.**

Tax calculated on discounted amount (standard accounting practice, matches Stripe Tax).

For tax-inclusive pricing:
- Discount subtracted from gross (tax-inclusive) subtotal
- Tax then extracted from post-discount gross: `tax = post_discount_gross * rate / (1 + rate)`
- Tax portion decreases proportionally with discount

**Pipeline order**:
1. Calculate line subtotals
2. Apply discount allocations to lines
3. Calculate tax on discounted line amounts
4. Sum for total

---

## SUMMARY TABLE

| Component | Key Rule | Details |
|---|---|---|
| **Cart Version** | Optimistic concurrency | Increment on every mutation, compare expected_version on API calls |
| **Session Binding** | Guest carts | Session key `cart_id` stores cart ID, cleared on login |
| **Merge on Login** | Max quantity logic | Max(guest_qty, customer_qty) for duplicate variants |
| **Checkout State** | 6-state machine | started -> addressed -> shipping_selected -> payment_selected -> completed or expired |
| **Address Transition** | Address validation | Email + all shipping fields required, triggers pricing recalc |
| **Shipping Transition** | Skip if no physical items | status = shipping_selected with null method/amount if no requires_shipping |
| **Payment Selection** | Inventory reserve | Inventory reserved when entering payment_selected state |
| **Order Creation** | Idempotent | Must check if order already exists before creating |
| **Order Status by Method** | credit_card/paypal | status=paid, financial_status=paid, inventory committed |
| **Order Status by Method** | bank_transfer | status=pending, financial_status=pending, inventory reserved |
| **Pricing Pipeline** | 7 steps | Subtotals -> cart subtotal -> discount -> discounted -> shipping -> tax -> total |
| **Rounding** | Line-level | Each line rounded independently, last line gets remainder |
| **Discount Types** | code/automatic | 1 code max, multiple automatics can stack |
| **Discount Value** | percent/fixed/free_shipping | Percent = whole number (15=15%), fixed = cents, free_shipping = override |
| **Discount Allocation** | Proportional | Distributed across qualifying lines, remainder to last line |
| **Discount Validation** | 8-step flow | Existence -> status -> date range -> usage limit -> min purchase -> applicability |
| **Shipping Zones** | Best match wins | Country+region=2, country only=1, tie-break by lowest ID |
| **Shipping Rate Types** | 4 types | flat, weight-based, price-based, carrier (stub) |
| **Free Shipping Override** | Discount-driven | free_shipping discount sets shipping = 0 |
| **Tax-Inclusive** | Extract from gross | intdiv(gross * 10000, 10000 + rate_bps) |
| **Tax-Exclusive** | Add to net | ROUND(net * rate / 10000) |
| **Discount-Before-Tax** | Always | Tax calculated on discounted amount |
| **Pricing Snapshot** | JSON storage | Full PricingResult stored in checkouts.totals_json |
| **Pricing Recalc** | Triggers | Address change, shipping method change, discount change |

---

All rules are now extracted with complete detail, validation sequences, algorithms, and edge cases.

text:
agentId: a30cf76 (for resuming to continue this agent's work if needed)
<usage>total_tokens: 58014
tool_uses: 1
duration_ms: 66977</usage>

status: completed

prompt:
Read the business logic spec at /Users/fabianwesner/Herd/shop/specs/05-BUSINESS-LOGIC.md and extract ALL the rules and details related to:
1. Cart operations (versioning, session binding, merge on login)
2. Checkout state machine (all transitions, validation rules)
3. Pricing engine (pipeline steps, rounding rules)
4. Discount system (validation rules, allocation, types)
5. Shipping calculator (zone matching, rate types, calculations)
6. Tax calculator (exclusive vs inclusive, basis points)

Return the complete business logic rules with all details.

service_tier: standard

speed: standard


type: message

role: assistant

type: assistant


type: thinking

thinking:
Now I have all the specs needed to launch Phase 4 and Phase 5. Let me also read the existing codebase files that the agents will need to know about (tests/Pest.php helpers, existing models, etc.) before launching the agents.

Let me now launch Phase 4 as a dedicated agent. Phase 4 is a big phase with many components:
- Migrations: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts
- Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
- Enums: CartStatus, CheckoutStatus, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode

- Services: CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService
- Value Objects: PricingResult, TaxLine
- Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts
- Exceptions: CartVersionMismatchException, InvalidDiscountException, and related error handling
- Tests: comprehensive coverage including CartServiceTest, CartApiTest, CheckoutFlowTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest, and calculator tests

This is a substantial implementation phase, so I'm going to launch it as a single agent with all the specification details. I should also pull in the current Pest configuration to include in the prompt.


text: Good, I have all the spec details. Let me read the current test helpers and existing code, then launch Phase 4.


type: progress

hookEvent: PostToolUse

command: callback


content:
     1→<?php
     2→
     3→use App\Enums\StoreUserRole;
     4→use App\Models\Organization;
     5→use App\Models\Store;
     6→use App\Models\StoreDomain;
     7→use App\Models\User;
     8→use Illuminate\Foundation\Testing\RefreshDatabase;
     9→
    10→pest()->extend(Tests\TestCase::class)
    11→    ->use(RefreshDatabase::class)
    12→    ->in('Feature');
    13→
    14→expect()->extend('toBeOne', function () {
    15→    return $this->toBe(1);
    16→});
    17→
    18→/**
    19→ * Create a full store context: Organization, Store, StoreDomain, User with Owner role, and bind current_store.
    20→ *
    21→ * @return array{organization: Organization, store: Store, domain: StoreDomain, user: User}
    22→ */
    23→function createStoreContext(): array
    24→{
    25→    $organization = Organization::factory()->create();
    26→    $store = Store::factory()->for($organization)->create();
    27→    $domain = StoreDomain::factory()->for($store)->create([
    28→        'hostname' => 'test-store.example.com',
    29→        'is_primary' => true,
    30→    ]);
    31→
    32→    $user = User::factory()->create();
    33→    $user->stores()->attach($store->id, ['role' => StoreUserRole::Owner->value]);
    34→
    35→    app()->singleton('current_store', fn () => $store);
    36→
    37→    return compact('organization', 'store', 'domain', 'user');
    38→}
    39→
    40→/**
    41→ * Authenticate the given user and set the current store in session.
    42→ */
    43→function actingAsAdmin(User $user, ?Store $store = null): Tests\TestCase
    44→{
    45→    $store ??= app()->bound('current_store') ? app('current_store') : null;
    46→
    47→    $testCase = test()->actingAs($user);
    48→
    49→    if ($store) {
    50→        session(['current_store_id' => $store->id]);
    51→    }
    52→
    53→    return $testCase;
    54→}
    55→

content:
<?php

use App\Enums\StoreUserRole;
use App\Models\Organization;
use App\Models\Store;
use App\Models\StoreDomain;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

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

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

/**
 * Create a full store context: Organization, Store, StoreDomain, User with Owner role, and bind current_store.
 *
 * @return array{organization: Organization, store: Store, domain: StoreDomain, user: User}
 */
function createStoreContext(): array
{
    $organization = Organization::factory()->create();
    $store = Store::factory()->for($organization)->create();
    $domain = StoreDomain::factory()->for($store)->create([
        'hostname' => 'test-store.example.com',
        'is_primary' => true,
    ]);

    $user = User::factory()->create();
    $user->stores()->attach($store->id, ['role' => StoreUserRole::Owner->value]);

    app()->singleton('current_store', fn () => $store);

    return compact('organization', 'store', 'domain', 'user');
}

/**
 * Authenticate the given user and set the current store in session.
 */
function actingAsAdmin(User $user, ?Store $store = null): Tests\TestCase
{
    $store ??= app()->bound('current_store') ? app('current_store') : null;

    $testCase = test()->actingAs($user);

    if ($store) {
        session(['current_store_id' => $store->id]);
    }

    return $testCase;
}


content:
     1→<?php
     2→
     3→namespace App\Models\Concerns;
     4→
     5→use App\Models\Scopes\StoreScope;
     6→use App\Models\Store;
     7→use Illuminate\Database\Eloquent\Relations\BelongsTo;
     8→
     9→trait BelongsToStore
    10→{
    11→    public static function bootBelongsToStore(): void
    12→    {
    13→        static::addGlobalScope(new StoreScope);
    14→
    15→        static::creating(function ($model): void {
    16→            if (! $model->store_id && app()->bound('current_store')) {
    17→                $model->store_id = app('current_store')->id;
    18→            }
    19→        });
    20→    }
    21→
    22→    /**
    23→     * @return BelongsTo<Store, $this>
    24→     */
    25→    public function store(): BelongsTo
    26→    {
    27→        return $this->belongsTo(Store::class);
    28→    }
    29→}
    30→

content:
<?php

namespace App\Models\Concerns;

use App\Models\Scopes\StoreScope;
use App\Models\Store;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

trait BelongsToStore
{
    public static function bootBelongsToStore(): void
    {
        static::addGlobalScope(new StoreScope);

        static::creating(function ($model): void {
            if (! $model->store_id && app()->bound('current_store')) {
                $model->store_id = app('current_store')->id;
            }
        });
    }

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }
}


content:
     1→<?php
     2→
     3→namespace App\Services;
     4→
     5→use App\Enums\InventoryPolicy;
     6→use App\Exceptions\InsufficientInventoryException;
     7→use App\Models\InventoryItem;
     8→use Illuminate\Support\Facades\DB;
     9→
    10→class InventoryService
    11→{
    12→    public function checkAvailability(InventoryItem $item, int $quantity): bool
    13→    {
    14→        if ($item->policy === InventoryPolicy::Continue) {
    15→            return true;
    16→        }
    17→
    18→        return $item->available() >= $quantity;
    19→    }
    20→
    21→    public function reserve(InventoryItem $item, int $quantity): void
    22→    {
    23→        DB::transaction(function () use ($item, $quantity): void {
    24→            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);
    25→
    26→            if ($item->policy === InventoryPolicy::Deny && $item->available() < $quantity) {
    27→                throw new InsufficientInventoryException(
    28→                    requested: $quantity,
    29→                    available: $item->available(),
    30→                );
    31→            }
    32→
    33→            $item->update([
    34→                'quantity_reserved' => $item->quantity_reserved + $quantity,
    35→            ]);
    36→        });
    37→    }
    38→
    39→    public function release(InventoryItem $item, int $quantity): void
    40→    {
    41→        DB::transaction(function () use ($item, $quantity): void {
    42→            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);
    43→
    44→            $item->update([
    45→                'quantity_reserved' => max(0, $item->quantity_reserved - $quantity),
    46→            ]);
    47→        });
    48→    }
    49→
    50→    public function commit(InventoryItem $item, int $quantity): void
    51→    {
    52→        DB::transaction(function () use ($item, $quantity): void {
    53→            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);
    54→
    55→            $item->update([
    56→                'quantity_on_hand' => $item->quantity_on_hand - $quantity,
    57→                'quantity_reserved' => max(0, $item->quantity_reserved - $quantity),
    58→            ]);
    59→        });
    60→    }
    61→
    62→    public function restock(InventoryItem $item, int $quantity): void
    63→    {
    64→        DB::transaction(function () use ($item, $quantity): void {
    65→            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);
    66→
    67→            $item->update([
    68→                'quantity_on_hand' => $item->quantity_on_hand + $quantity,
    69→            ]);
    70→        });
    71→    }
    72→}
    73→

content:
<?php

namespace App\Services;

use App\Enums\InventoryPolicy;
use App\Exceptions\InsufficientInventoryException;
use App\Models\InventoryItem;
use Illuminate\Support\Facades\DB;

class InventoryService
{
    public function checkAvailability(InventoryItem $item, int $quantity): bool
    {
        if ($item->policy === InventoryPolicy::Continue) {
            return true;
        }

        return $item->available() >= $quantity;
    }

    public function reserve(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);

            if ($item->policy === InventoryPolicy::Deny && $item->available() < $quantity) {
                throw new InsufficientInventoryException(
                    requested: $quantity,
                    available: $item->available(),
                );
            }

            $item->update([
                'quantity_reserved' => $item->quantity_reserved + $quantity,
            ]);
        });
    }

    public function release(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);

            $item->update([
                'quantity_reserved' => max(0, $item->quantity_reserved - $quantity),
            ]);
        });
    }

    public function commit(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);

            $item->update([
                'quantity_on_hand' => $item->quantity_on_hand - $quantity,
                'quantity_reserved' => max(0, $item->quantity_reserved - $quantity),
            ]);
        });
    }

    public function restock(InventoryItem $item, int $quantity): void
    {
        DB::transaction(function () use ($item, $quantity): void {
            $item = InventoryItem::withoutGlobalScopes()->lockForUpdate()->find($item->id);

            $item->update([
                'quantity_on_hand' => $item->quantity_on_hand + $quantity,
            ]);
        });
    }
}


content:
     1→<?php
     2→
     3→namespace App\Models;
     4→
     5→use App\Enums\VariantStatus;
     6→use Illuminate\Database\Eloquent\Factories\HasFactory;
     7→use Illuminate\Database\Eloquent\Model;
     8→use Illuminate\Database\Eloquent\Relations\BelongsTo;
     9→use Illuminate\Database\Eloquent\Relations\BelongsToMany;
    10→use Illuminate\Database\Eloquent\Relations\HasOne;
    11→
    12→class ProductVariant extends Model
    13→{
    14→    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    15→    use HasFactory;
    16→
    17→    /**
    18→     * @var list<string>
    19→     */
    20→    protected $fillable = [
    21→        'product_id',
    22→        'sku',
    23→        'barcode',
    24→        'price_amount',
    25→        'compare_at_amount',
    26→        'currency',
    27→        'weight_g',
    28→        'requires_shipping',
    29→        'is_default',
    30→        'position',
    31→        'status',
    32→    ];
    33→
    34→    /**
    35→     * @return array<string, string>
    36→     */
    37→    protected function casts(): array
    38→    {
    39→        return [
    40→            'status' => VariantStatus::class,
    41→            'requires_shipping' => 'boolean',
    42→            'is_default' => 'boolean',
    43→            'price_amount' => 'integer',
    44→            'compare_at_amount' => 'integer',
    45→            'weight_g' => 'integer',
    46→        ];
    47→    }
    48→
    49→    /**
    50→     * @return BelongsTo<Product, $this>
    51→     */
    52→    public function product(): BelongsTo
    53→    {
    54→        return $this->belongsTo(Product::class);
    55→    }
    56→
    57→    /**
    58→     * @return HasOne<InventoryItem, $this>
    59→     */
    60→    public function inventoryItem(): HasOne
    61→    {
    62→        return $this->hasOne(InventoryItem::class, 'variant_id');
    63→    }
    64→
    65→    /**
    66→     * @return BelongsToMany<ProductOptionValue, $this>
    67→     */
    68→    public function optionValues(): BelongsToMany
    69→    {
    70→        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    71→    }
    72→}
    73→

content:
<?php

namespace App\Models;

use App\Enums\VariantStatus;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasOne;

class ProductVariant extends Model
{
    /** @use HasFactory<\Database\Factories\ProductVariantFactory> */
    use HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'product_id',
        'sku',
        'barcode',
        'price_amount',
        'compare_at_amount',
        'currency',
        'weight_g',
        'requires_shipping',
        'is_default',
        'position',
        'status',
    ];

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => VariantStatus::class,
            'requires_shipping' => 'boolean',
            'is_default' => 'boolean',
            'price_amount' => 'integer',
            'compare_at_amount' => 'integer',
            'weight_g' => 'integer',
        ];
    }

    /**
     * @return BelongsTo<Product, $this>
     */
    public function product(): BelongsTo
    {
        return $this->belongsTo(Product::class);
    }

    /**
     * @return HasOne<InventoryItem, $this>
     */
    public function inventoryItem(): HasOne
    {
        return $this->hasOne(InventoryItem::class, 'variant_id');
    }

    /**
     * @return BelongsToMany<ProductOptionValue, $this>
     */
    public function optionValues(): BelongsToMany
    {
        return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
    }
}


content:
     1→<?php
     2→
     3→namespace App\Models;
     4→
     5→use App\Enums\ProductStatus;
     6→use App\Models\Concerns\BelongsToStore;
     7→use Illuminate\Database\Eloquent\Factories\HasFactory;
     8→use Illuminate\Database\Eloquent\Model;
     9→use Illuminate\Database\Eloquent\Relations\BelongsTo;
    10→use Illuminate\Database\Eloquent\Relations\BelongsToMany;
    11→use Illuminate\Database\Eloquent\Relations\HasMany;
    12→
    13→class Product extends Model
    14→{
    15→    /** @use HasFactory<\Database\Factories\ProductFactory> */
    16→    use BelongsToStore, HasFactory;
    17→
    18→    /**
    19→     * @var list<string>
    20→     */
    21→    protected $fillable = [
    22→        'store_id',
    23→        'title',
    24→        'handle',
    25→        'status',
    26→        'description_html',
    27→        'vendor',
    28→        'product_type',
    29→        'tags',
    30→        'published_at',
    31→    ];
    32→
    33→    /**
    34→     * @return array<string, string>
    35→     */
    36→    protected function casts(): array
    37→    {
    38→        return [
    39→            'status' => ProductStatus::class,
    40→            'tags' => 'array',
    41→            'published_at' => 'datetime',
    42→        ];
    43→    }
    44→
    45→    /**
    46→     * @return BelongsTo<Store, $this>
    47→     */
    48→    public function store(): BelongsTo
    49→    {
    50→        return $this->belongsTo(Store::class);
    51→    }
    52→
    53→    /**
    54→     * @return HasMany<ProductVariant, $this>
    55→     */
    56→    public function variants(): HasMany
    57→    {
    58→        return $this->hasMany(ProductVariant::class);
    59→    }
    60→
    61→    /**
    62→     * @return HasMany<ProductOption, $this>
    63→     */
    64→    public function options(): HasMany
    65→    {
    66→        return $this->hasMany(ProductOption::class);
    67→    }
    68→
    69→    /**
    70→     * @return HasMany<ProductMedia, $this>
    71→     */
    72→    public function media(): HasMany
    73→    {
    74→        return $this->hasMany(ProductMedia::class);
    75→    }
    76→
    77→    /**
    78→     * @return BelongsToMany<Collection, $this>
    79→     */
    80→    public function collections(): BelongsToMany
    81→    {
    82→        return $this->belongsToMany(Collection::class, 'collection_products')
    83→            ->withPivot('position');
    84→    }
    85→}
    86→

content:
<?php

namespace App\Models;

use App\Enums\ProductStatus;
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Product extends Model
{
    /** @use HasFactory<\Database\Factories\ProductFactory> */
    use BelongsToStore, HasFactory;

    /**
     * @var list<string>
     */
    protected $fillable = [
        'store_id',
        'title',
        'handle',
        'status',
        'description_html',
        'vendor',
        'product_type',
        'tags',
        'published_at',
    ];

    /**
     * @return array<string, string>
     */
    protected function casts(): array
    {
        return [
            'status' => ProductStatus::class,
            'tags' => 'array',
            'published_at' => 'datetime',
        ];
    }

    /**
     * @return BelongsTo<Store, $this>
     */
    public function store(): BelongsTo
    {
        return $this->belongsTo(Store::class);
    }

    /**
     * @return HasMany<ProductVariant, $this>
     */
    public function variants(): HasMany
    {
        return $this->hasMany(ProductVariant::class);
    }

    /**
     * @return HasMany<ProductOption, $this>
     */
    public function options(): HasMany
    {
        return $this->hasMany(ProductOption::class);
    }

    /**
     * @return HasMany<ProductMedia, $this>
     */
    public function media(): HasMany
    {
        return $this->hasMany(ProductMedia::class);
    }

    /**
     * @return BelongsToMany<Collection, $this>
     */
    public function collections(): BelongsToMany
    {
        return $this->belongsToMany(Collection::class, 'collection_products')
            ->withPivot('position');
    }
}


content:
     1→<?php
     2→
     3→use Illuminate\Support\Facades\Route;
     4→
     5→Route::prefix('v1')->name('api.v1.')->group(function () {
     6→    Route::middleware('throttle:api.storefront')->group(function () {
     7→        Route::get('health', function () {
     8→            return response()->json(['status' => 'ok']);
     9→        })->name('health');
    10→    });
    11→});
    12→

content:
<?php

use Illuminate\Support\Facades\Route;

Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::middleware('throttle:api.storefront')->group(function () {
        Route::get('health', function () {
            return response()->json(['status' => 'ok']);
        })->name('health');
    });
});


content:
     1→<?php
     2→
     3→use Illuminate\Foundation\Application;
     4→use Illuminate\Foundation\Configuration\Exceptions;
     5→use Illuminate\Foundation\Configuration\Middleware;
     6→
     7→return Application::configure(basePath: dirname(__DIR__))
     8→    ->withRouting(
     9→        web: __DIR__.'/../routes/web.php',
    10→        api: __DIR__.'/../routes/api.php',
    11→        commands: __DIR__.'/../routes/console.php',
    12→        health: '/up',
    13→    )
    14→    ->withMiddleware(function (Middleware $middleware): void {
    15→        $middleware->alias([
    16→            'store.resolve' => \App\Http\Middleware\ResolveStore::class,
    17→            'role.check' => \App\Http\Middleware\CheckStoreRole::class,
    18→        ]);
    19→    })
    20→    ->withExceptions(function (Exceptions $exceptions): void {
    21→        //
    22→    })->create();
    23→

content:
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
        commands: __DIR__.'/../routes/console.php',
        health: '/up',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->alias([
            'store.resolve' => \App\Http\Middleware\ResolveStore::class,
            'role.check' => \App\Http\Middleware\CheckStoreRole::class,
        ]);
    })
    ->withExceptions(function (Exceptions $exceptions): void {
        //
    })->create();


text: Now I have everything needed. Let me update the task status and launch Phase 4 as a dedicated agent.


TaskUpdate

activeForm: Building Phase 4: Cart, Checkout, Discounts, Shipping, Taxes


content: Updated task #4 activeForm, status

updatedFields: activeForm status

from: pending


Task

description: Phase 4: Cart/Checkout/Discounts

prompt:
You are implementing Phase 4 of an e-commerce platform: Cart, Checkout, Discounts, Shipping, Taxes.

The project uses PHP 8.4 / Laravel 12 / Livewire v4 / SQLite / Pest v4. Working directory: /Users/fabianwesner/Herd/shop

Phases 1-3 are already complete. You must create all of the following for Phase 4. DO NOT modify any existing files except where explicitly noted (routes, Pest.php, bootstrap/app.php).

IMPORTANT RULES:
- Use explicit PHP return types on all methods
- Use constructor property promotion
- Use PHP enums with string-backed values
- All monetary amounts are INTEGER in minor units (cents)
- Tax rates in basis points (1900 = 19%)
- Use BelongsToStore trait for store-scoped models (see app/Models/Concerns/BelongsToStore.php)
- All tests must use Pest syntax
- Run `vendor/bin/pint --dirty` before finishing
- Never use em-dash character

## 1. ENUMS TO CREATE (app/Enums/)

Create these string-backed PHP enums:

**CartStatus.php**: Active('active'), Converted('converted'), Abandoned('abandoned')
**CheckoutStatus.php**: Started('started'), Addressed('addressed'), ShippingSelected('shipping_selected'), PaymentSelected('payment_selected'), Completed('completed'), Expired('expired')
**DiscountType.php**: Code('code'), Automatic('automatic')
**DiscountValueType.php**: Percent('percent'), Fixed('fixed'), FreeShipping('free_shipping')
**DiscountStatus.php**: Draft('draft'), Active('active'), Expired('expired'), Disabled('disabled')
**ShippingRateType.php**: Flat('flat'), Weight('weight'), Price('price'), Carrier('carrier')
**TaxMode.php**: Manual('manual'), Provider('provider')

## 2. MIGRATIONS (database/migrations/)

Create in order with timestamps 2025_08_15_000030 through 000036:

**000030_create_carts_table**: id, store_id (FK stores CASCADE), customer_id (FK nullable, will reference customers table when it exists - for now just integer nullable), currency TEXT NOT NULL default 'USD', cart_version INTEGER NOT NULL default 1, status TEXT NOT NULL default 'active' CHECK IN ('active','converted','abandoned'), timestamps. Indexes: idx_carts_store_id, idx_carts_customer_id, idx_carts_store_status(store_id, status)

**000031_create_cart_lines_table**: id, cart_id (FK carts CASCADE), variant_id (FK product_variants CASCADE), quantity INTEGER NOT NULL default 1, unit_price_amount INTEGER NOT NULL default 0, line_subtotal_amount INTEGER NOT NULL default 0, line_discount_amount INTEGER NOT NULL default 0, line_total_amount INTEGER NOT NULL default 0. Indexes: idx_cart_lines_cart_id, UNIQUE(cart_id, variant_id). No timestamps.

**000032_create_checkouts_table**: id, store_id (FK stores CASCADE), cart_id (FK carts CASCADE), customer_id (INTEGER nullable), status TEXT NOT NULL default 'started' CHECK, payment_method TEXT nullable CHECK IN ('credit_card','paypal','bank_transfer'), email TEXT nullable, shipping_address_json TEXT nullable, billing_address_json TEXT nullable, shipping_method_id INTEGER nullable, discount_code TEXT nullable, tax_provider_snapshot_json TEXT nullable, totals_json TEXT nullable, expires_at TEXT nullable, timestamps. Indexes: idx_checkouts_store_id, idx_checkouts_cart_id, idx_checkouts_customer_id, idx_checkouts_status(store_id, status), idx_checkouts_expires_at(expires_at)

**000033_create_shipping_zones_table**: id, store_id (FK stores CASCADE), name TEXT NOT NULL, countries_json TEXT NOT NULL default '[]', regions_json TEXT NOT NULL default '[]'. Indexes: idx_shipping_zones_store_id. No timestamps.

**000034_create_shipping_rates_table**: id, zone_id (FK shipping_zones CASCADE), name TEXT NOT NULL, type TEXT NOT NULL default 'flat' CHECK IN ('flat','weight','price','carrier'), config_json TEXT NOT NULL default '{}', is_active INTEGER NOT NULL default 1. Indexes: idx_shipping_rates_zone_id, idx_shipping_rates_zone_active(zone_id, is_active). No timestamps.

**000035_create_tax_settings_table**: store_id (PK, FK stores CASCADE), mode TEXT NOT NULL default 'manual' CHECK, provider TEXT NOT NULL default 'none' CHECK IN ('stripe_tax','none'), prices_include_tax INTEGER NOT NULL default 0, config_json TEXT NOT NULL default '{}'. No timestamps, no id column. store_id is primary key.

**000036_create_discounts_table**: id, store_id (FK stores CASCADE), type TEXT NOT NULL default 'code' CHECK, code TEXT nullable, value_type TEXT NOT NULL CHECK IN ('fixed','percent','free_shipping'), value_amount INTEGER NOT NULL default 0, starts_at TEXT NOT NULL, ends_at TEXT nullable, usage_limit INTEGER nullable, usage_count INTEGER NOT NULL default 0, rules_json TEXT NOT NULL default '{}', status TEXT NOT NULL default 'active' CHECK IN ('draft','active','expired','disabled'), timestamps. Indexes: UNIQUE(store_id, code), idx_discounts_store_id, idx_discounts_store_status(store_id, status), idx_discounts_store_type(store_id, type)

## 3. MODELS (app/Models/)

**Cart.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, customer_id, currency, cart_version, status. Casts: status => CartStatus, cart_version => integer. Relationships: lines() hasMany(CartLine), checkouts() hasMany(Checkout). Note: customer_id relationship will be added in Phase 5.

**CartLine.php**: HasFactory. Fillable: cart_id, variant_id, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount, line_total_amount. Casts: all amount fields as integer, quantity as integer. Relationships: cart() belongsTo(Cart), variant() belongsTo(ProductVariant). public $timestamps = false;

**Checkout.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, cart_id, customer_id, status, payment_method, email, shipping_address_json, billing_address_json, shipping_method_id, discount_code, tax_provider_snapshot_json, totals_json, expires_at. Casts: status => CheckoutStatus, shipping_address_json => array, billing_address_json => array, totals_json => array, tax_provider_snapshot_json => array. Relationships: cart() belongsTo(Cart), shippingRate() belongsTo(ShippingRate, 'shipping_method_id').

**ShippingZone.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, name, countries_json, regions_json. Casts: countries_json => array, regions_json => array. Relationships: rates() hasMany(ShippingRate, 'zone_id'). public $timestamps = false;

**ShippingRate.php**: HasFactory. Fillable: zone_id, name, type, config_json, is_active. Casts: type => ShippingRateType, config_json => array, is_active => boolean. Relationships: zone() belongsTo(ShippingZone). public $timestamps = false;

**TaxSettings.php**: HasFactory. Table: 'tax_settings'. PK: store_id (NOT auto-increment). public $incrementing = false; Fillable: store_id, mode, provider, prices_include_tax, config_json. Casts: mode => TaxMode, prices_include_tax => boolean, config_json => array. Relationships: store() belongsTo(Store). public $timestamps = false;

**Discount.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, type, code, value_type, value_amount, starts_at, ends_at, usage_limit, usage_count, rules_json, status. Casts: type => DiscountType, value_type => DiscountValueType, status => DiscountStatus, rules_json => array, starts_at => datetime, ends_at => datetime, value_amount => integer, usage_count => integer. Relationships: store() belongsTo(Store).

## 4. FACTORIES (database/factories/)

Create factories for ALL models above. Use sensible defaults. For Cart: currency 'USD', cart_version 1, status CartStatus::Active. For Discount: sensible defaults with starts_at = now()-1day, ends_at = now()+30days, status Active.

## 5. VALUE OBJECTS (app/ValueObjects/)

**PricingResult.php**: Readonly class with constructor: int $subtotal, int $discount, int $shipping, array $taxLines, int $taxTotal, int $total, string $currency. Add a toArray() method returning associative array.

**TaxLine.php**: Readonly class with constructor: string $name, int $rate (basis points), int $amount (cents). Add toArray() method.

**DiscountResult.php**: Readonly class with constructor: int $totalDiscount, bool $freeShipping, array $lineAllocations (keyed by line ID => discount amount). Add toArray() method.

## 6. EXCEPTIONS (app/Exceptions/)

**CartVersionMismatchException.php**: extends RuntimeException. Constructor takes int $expected, int $actual.
**InvalidDiscountException.php**: extends RuntimeException. Constructor takes string $reasonCode, string $message.
**InvalidCheckoutTransitionException.php**: extends RuntimeException.
**PaymentFailedException.php**: extends RuntimeException. Constructor takes string $errorCode, ?string $message.
**FulfillmentGuardException.php**: extends RuntimeException.

## 7. SERVICES

### app/Services/CartService.php

Methods:
- create(Store $store, ?int $customerId = null): Cart - creates cart with store currency, version 1
- addLine(Cart $cart, int $variantId, int $quantity): CartLine - validates variant active, product active, inventory check (deny policy), increments existing line or creates new, recalculates amounts, increments version
- updateLineQuantity(Cart $cart, int $lineId, int $quantity): ?CartLine - if qty=0 removes line, else updates, recalculates, increments version. Returns null if removed.
- removeLine(Cart $cart, int $lineId): void - deletes line, increments version
- getOrCreateForSession(Store $store, ?int $customerId = null): Cart - session-based cart binding using session('cart_id')
- mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart - merges guest into customer, marks guest abandoned
- validateVersion(Cart $cart, int $expectedVersion): void - throws CartVersionMismatchException if mismatch

All mutations must increment cart_version. Use DB transactions.

### app/Services/DiscountService.php

Methods:
- validate(string $code, Store $store, Cart $cart): \App\Models\Discount - case-insensitive lookup, validates status=active, dates, usage limit, min purchase. Throws InvalidDiscountException with reason codes: not_found, expired, not_yet_active, usage_limit_reached, minimum_not_met
- calculate(Discount $discount, int $subtotal, array $lines): DiscountResult - calculates amount based on value_type (percent/fixed/free_shipping), allocates proportionally across qualifying lines, remainder to last line

### app/Services/ShippingCalculator.php

Methods:
- getAvailableRates(Store $store, array $address): \Illuminate\Support\Collection - matches zones by country_code then province_code, returns all active rates from matching zones
- calculate(ShippingRate $rate, Cart $cart): int - calculates cost based on rate type: flat (fixed amount), weight (sum variant weights * qty, match range), price (cart subtotal match range)

Zone matching: check countries_json for country_code, then regions_json for province_code. Best specificity wins (country+region=2, country=1). Tie-break by lowest zone ID.

### app/Services/TaxCalculator.php

Methods:
- calculate(int $amount, TaxSettings $settings, array $address): TaxLine - calculates tax using manual rate from config_json. Default rate from config_json 'default_rate' key (basis points).
- extractInclusive(int $grossAmount, int $rateBasisPoints): int - extracts tax: net = intdiv(gross * 10000, 10000 + rate), tax = gross - net
- addExclusive(int $netAmount, int $rateBasisPoints): int - adds tax: tax = intdiv(net * rate, 10000) using integer division (round via intdiv)

### app/Services/PricingEngine.php

Method: calculate(Checkout $checkout): PricingResult

Pipeline:
1. Line subtotals: unit_price * quantity per line
2. Cart subtotal: sum of line subtotals
3. Discount: if discount_code set, validate + calculate, allocate to lines
4. Discounted subtotal: subtotal - discount
5. Shipping: if shipping_method_id set, calculate via ShippingCalculator
6. Tax: on discounted amounts. If prices_include_tax, extract. Else add exclusive. Also tax shipping if configured.
7. Total: discounted_subtotal + shipping + tax_total

Store result in checkout.totals_json.

Use constructor injection for CartService, DiscountService, ShippingCalculator, TaxCalculator.

### app/Services/CheckoutService.php

State machine methods:
- createCheckout(Cart $cart): Checkout - creates checkout from cart, status=started
- setAddress(Checkout $checkout, array $data): Checkout - validates email + address fields, saves, recalculates pricing, transitions to addressed
- setShippingMethod(Checkout $checkout, int $rateId): Checkout - validates rate belongs to applicable zone, saves, recalculates, transitions to shipping_selected. If no items require shipping, skip to shipping_selected with null method and 0 shipping.
- selectPaymentMethod(Checkout $checkout, string $method): Checkout - validates, stores method, reserves inventory via InventoryService for all cart lines, sets expires_at = now+24h, transitions to payment_selected
- completeCheckout(Checkout $checkout, array $paymentDetails): mixed - IDEMPOTENT: if order exists return it. This method will be fully implemented in Phase 5 when Order model exists. For now, just transition to completed status and return the checkout.
- expireCheckout(Checkout $checkout): void - releases inventory if reserved, transitions to expired

Validate state transitions - throw InvalidCheckoutTransitionException for invalid ones.

## 8. JOBS (app/Jobs/)

**ExpireAbandonedCheckouts.php**: Artisan schedulable. Finds checkouts where status NOT IN (completed, expired) AND updated_at < now - 24 hours. For each: if status was payment_selected, release inventory. Set status = expired.

**CleanupAbandonedCarts.php**: Artisan schedulable. Finds carts where status = active AND updated_at < now - 14 days. Marks as abandoned.

Register both in routes/console.php:
```php
use Illuminate\Support\Facades\Schedule;
Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily();
```

## 9. TESTS

Create ALL these test files with ALL the test cases listed. Use Pest syntax. Use the createStoreContext() helper from tests/Pest.php.

### tests/Unit/PricingEngineTest.php (14 tests)
- calculates subtotal from line items (2 lines: 2499x2 + 7999x1 = 12997)
- calculates subtotal for a single line (1500x3 = 4500)
- returns zero subtotal for empty cart
- applies percent discount correctly (10000 subtotal, 10% = 1000 discount)
- applies fixed discount correctly (10000 subtotal, 500 fixed)
- caps fixed discount at subtotal so it never goes negative (300 subtotal, 500 fixed = 300 discount)
- applies free shipping discount by zeroing shipping
- calculates tax exclusive correctly (10000 net, 1900bp = 1900 tax)
- extracts tax from inclusive price correctly (11900 gross, 1900bp = 1900 tax, 10000 net)
- returns zero tax when rate is zero
- calculates shipping flat rate (499)
- calculates full checkout totals end to end
- handles rounding correctly with odd cent amounts
- produces identical results for identical inputs

### tests/Unit/DiscountCalculatorTest.php (13 tests)
- validates an active discount code
- rejects an expired discount code
- rejects a not-yet-active discount code
- rejects a discount that has reached its usage limit
- rejects an unknown discount code
- performs case-insensitive code lookup
- enforces minimum purchase amount rule
- passes minimum purchase when cart meets threshold
- calculates percent discount amount (15%, 10000 = 1500)
- calculates fixed discount amount
- handles free shipping discount type
- allocates discount proportionally across multiple lines
- distributes rounding remainder to the last qualifying line

### tests/Unit/TaxCalculatorTest.php (7 tests)
- calculates manual tax exclusive (10000 net, 1900bp = 1900)
- extracts manual tax from inclusive amount (11900 gross, 1900bp = 1900 tax)
- returns zero tax when no rate configured
- handles zero amount lines
- calculates tax with non-standard rate (8999, 700bp = 629)
- extracts tax correctly for small amounts (119 gross, 1900bp = 19 tax)
- handles high tax rates (10000, 2500bp = 2500)

### tests/Unit/ShippingCalculatorTest.php (9 tests)
- matches a zone by country code
- matches a zone by region code
- returns empty when no zone matches
- calculates a flat rate
- calculates a weight-based rate
- calculates a price-based rate
- returns zero shipping when no items require shipping
- returns correct rates when multiple zones match
- skips inactive rates

### tests/Unit/CartVersionTest.php (5 tests)
- starts at version 1
- increments version on add line
- increments version on update quantity
- increments version on remove line
- detects version mismatch

### tests/Feature/Cart/CartServiceTest.php (12 tests)
- creates a cart for the current store
- adds a line item to the cart
- increments quantity when adding an existing variant
- rejects add when product is not active
- rejects add when inventory is insufficient and policy is deny
- allows add when inventory is insufficient but policy is continue
- updates line quantity
- removes a line when quantity set to zero
- removes a specific line item
- increments cart version on every mutation
- returns cart via session for guest users
- merges guest cart into customer cart on login

### tests/Feature/Checkout/CheckoutFlowTest.php (5 tests)
- creates a checkout from a cart
- completes full checkout happy path (through payment_selected for now, full completion in Phase 5)
- rejects checkout for empty cart
- expires checkout after timeout
- prevents duplicate checkouts from same cart (if completed checkout exists)

### tests/Feature/Checkout/CheckoutStateTest.php (9 tests)
- transitions from started to addressed with valid address
- rejects address transition with missing required fields
- transitions from addressed to shipping_selected
- rejects shipping selection with rate from wrong zone
- skips shipping selection when no items require shipping
- transitions from shipping_selected to payment_selected
- transitions from payment_selected to completed (basic, no order creation yet)
- rejects invalid state transitions
- recalculates pricing on address change

### tests/Feature/Checkout/DiscountTest.php (6 tests)
- applies a valid percent discount code at checkout
- applies a valid fixed discount code at checkout
- removes discount when code is cleared
- rejects expired discount at checkout
- increments usage count on checkout completion (test the counter incrementing logic)
- handles free shipping discount at checkout

### tests/Feature/Checkout/ShippingTest.php (5 tests)
- returns available shipping rates for address
- returns empty when no zone matches address
- calculates flat rate correctly
- calculates weight-based rate correctly
- returns zero shipping when all items are digital

### tests/Feature/Checkout/TaxTest.php (4 tests)
- calculates exclusive tax correctly at checkout
- extracts inclusive tax correctly at checkout
- applies zero tax when no tax settings exist
- stores tax lines in totals_json

### tests/Feature/Checkout/PricingIntegrationTest.php (5 tests)
- calculates correct totals for a simple checkout
- applies discount code and recalculates
- stores pricing snapshot in totals_json
- recalculates on shipping method change
- handles prices-include-tax correctly

## EXECUTION ORDER

1. Create all enums
2. Create all migrations
3. Create all models and factories
4. Create value objects and exceptions
5. Create all services (CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService)
6. Create jobs and register in console.php
7. Create all tests
8. Run `php artisan migrate:fresh` to verify migrations
9. Run `php artisan test` to run all tests
10. Fix any failures
11. Run `vendor/bin/pint --dirty` to format

After creating everything, run the tests and fix ALL failures. Keep iterating until all tests pass.

IMPORTANT: The checkout completeCheckout method should be a stub for now (Phase 5 will add order creation). For Phase 4, just transition to completed. The test for "completes full checkout happy path" should test through to payment_selected status, not full order creation.


text:
Async agent launched successfully.
agentId: ac9a683 (internal ID - do not mention to user. Use to resume later if needed.)
The agent is working in the background. You will be notified automatically when it completes.
Continue with other tasks.
output_file: /private/tmp/claude-501/-Users-fabianwesner-Herd-shop/tasks/ac9a683.output
To check progress before completion (optional), use Read or Bash tail on the output file.

description: Phase 4: Cart/Checkout/Discounts

prompt:
You are implementing Phase 4 of an e-commerce platform: Cart, Checkout, Discounts, Shipping, Taxes.

The project uses PHP 8.4 / Laravel 12 / Livewire v4 / SQLite / Pest v4. Working directory: /Users/fabianwesner/Herd/shop

Phases 1-3 are already complete. You must create all of the following for Phase 4. DO NOT modify any existing files except where explicitly noted (routes, Pest.php, bootstrap/app.php).

IMPORTANT RULES:
- Use explicit PHP return types on all methods
- Use constructor property promotion
- Use PHP enums with string-backed values
- All monetary amounts are INTEGER in minor units (cents)
- Tax rates in basis points (1900 = 19%)
- Use BelongsToStore trait for store-scoped models (see app/Models/Concerns/BelongsToStore.php)
- All tests must use Pest syntax
- Run `vendor/bin/pint --dirty` before finishing
- Never use em-dash character

## 1. ENUMS TO CREATE (app/Enums/)

Create these string-backed PHP enums:

**CartStatus.php**: Active('active'), Converted('converted'), Abandoned('abandoned')
**CheckoutStatus.php**: Started('started'), Addressed('addressed'), ShippingSelected('shipping_selected'), PaymentSelected('payment_selected'), Completed('completed'), Expired('expired')
**DiscountType.php**: Code('code'), Automatic('automatic')
**DiscountValueType.php**: Percent('percent'), Fixed('fixed'), FreeShipping('free_shipping')
**DiscountStatus.php**: Draft('draft'), Active('active'), Expired('expired'), Disabled('disabled')
**ShippingRateType.php**: Flat('flat'), Weight('weight'), Price('price'), Carrier('carrier')
**TaxMode.php**: Manual('manual'), Provider('provider')

## 2. MIGRATIONS (database/migrations/)

Create in order with timestamps 2025_08_15_000030 through 000036:

**000030_create_carts_table**: id, store_id (FK stores CASCADE), customer_id (FK nullable, will reference customers table when it exists - for now just integer nullable), currency TEXT NOT NULL default 'USD', cart_version INTEGER NOT NULL default 1, status TEXT NOT NULL default 'active' CHECK IN ('active','converted','abandoned'), timestamps. Indexes: idx_carts_store_id, idx_carts_customer_id, idx_carts_store_status(store_id, status)

**000031_create_cart_lines_table**: id, cart_id (FK carts CASCADE), variant_id (FK product_variants CASCADE), quantity INTEGER NOT NULL default 1, unit_price_amount INTEGER NOT NULL default 0, line_subtotal_amount INTEGER NOT NULL default 0, line_discount_amount INTEGER NOT NULL default 0, line_total_amount INTEGER NOT NULL default 0. Indexes: idx_cart_lines_cart_id, UNIQUE(cart_id, variant_id). No timestamps.

**000032_create_checkouts_table**: id, store_id (FK stores CASCADE), cart_id (FK carts CASCADE), customer_id (INTEGER nullable), status TEXT NOT NULL default 'started' CHECK, payment_method TEXT nullable CHECK IN ('credit_card','paypal','bank_transfer'), email TEXT nullable, shipping_address_json TEXT nullable, billing_address_json TEXT nullable, shipping_method_id INTEGER nullable, discount_code TEXT nullable, tax_provider_snapshot_json TEXT nullable, totals_json TEXT nullable, expires_at TEXT nullable, timestamps. Indexes: idx_checkouts_store_id, idx_checkouts_cart_id, idx_checkouts_customer_id, idx_checkouts_status(store_id, status), idx_checkouts_expires_at(expires_at)

**000033_create_shipping_zones_table**: id, store_id (FK stores CASCADE), name TEXT NOT NULL, countries_json TEXT NOT NULL default '[]', regions_json TEXT NOT NULL default '[]'. Indexes: idx_shipping_zones_store_id. No timestamps.

**000034_create_shipping_rates_table**: id, zone_id (FK shipping_zones CASCADE), name TEXT NOT NULL, type TEXT NOT NULL default 'flat' CHECK IN ('flat','weight','price','carrier'), config_json TEXT NOT NULL default '{}', is_active INTEGER NOT NULL default 1. Indexes: idx_shipping_rates_zone_id, idx_shipping_rates_zone_active(zone_id, is_active). No timestamps.

**000035_create_tax_settings_table**: store_id (PK, FK stores CASCADE), mode TEXT NOT NULL default 'manual' CHECK, provider TEXT NOT NULL default 'none' CHECK IN ('stripe_tax','none'), prices_include_tax INTEGER NOT NULL default 0, config_json TEXT NOT NULL default '{}'. No timestamps, no id column. store_id is primary key.

**000036_create_discounts_table**: id, store_id (FK stores CASCADE), type TEXT NOT NULL default 'code' CHECK, code TEXT nullable, value_type TEXT NOT NULL CHECK IN ('fixed','percent','free_shipping'), value_amount INTEGER NOT NULL default 0, starts_at TEXT NOT NULL, ends_at TEXT nullable, usage_limit INTEGER nullable, usage_count INTEGER NOT NULL default 0, rules_json TEXT NOT NULL default '{}', status TEXT NOT NULL default 'active' CHECK IN ('draft','active','expired','disabled'), timestamps. Indexes: UNIQUE(store_id, code), idx_discounts_store_id, idx_discounts_store_status(store_id, status), idx_discounts_store_type(store_id, type)

## 3. MODELS (app/Models/)

**Cart.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, customer_id, currency, cart_version, status. Casts: status => CartStatus, cart_version => integer. Relationships: lines() hasMany(CartLine), checkouts() hasMany(Checkout). Note: customer_id relationship will be added in Phase 5.

**CartLine.php**: HasFactory. Fillable: cart_id, variant_id, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount, line_total_amount. Casts: all amount fields as integer, quantity as integer. Relationships: cart() belongsTo(Cart), variant() belongsTo(ProductVariant). public $timestamps = false;

**Checkout.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, cart_id, customer_id, status, payment_method, email, shipping_address_json, billing_address_json, shipping_method_id, discount_code, tax_provider_snapshot_json, totals_json, expires_at. Casts: status => CheckoutStatus, shipping_address_json => array, billing_address_json => array, totals_json => array, tax_provider_snapshot_json => array. Relationships: cart() belongsTo(Cart), shippingRate() belongsTo(ShippingRate, 'shipping_method_id').

**ShippingZone.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, name, countries_json, regions_json. Casts: countries_json => array, regions_json => array. Relationships: rates() hasMany(ShippingRate, 'zone_id'). public $timestamps = false;

**ShippingRate.php**: HasFactory. Fillable: zone_id, name, type, config_json, is_active. Casts: type => ShippingRateType, config_json => array, is_active => boolean. Relationships: zone() belongsTo(ShippingZone). public $timestamps = false;

**TaxSettings.php**: HasFactory. Table: 'tax_settings'. PK: store_id (NOT auto-increment). public $incrementing = false; Fillable: store_id, mode, provider, prices_include_tax, config_json. Casts: mode => TaxMode, prices_include_tax => boolean, config_json => array. Relationships: store() belongsTo(Store). public $timestamps = false;

**Discount.php**: Uses BelongsToStore trait, HasFactory. Fillable: store_id, type, code, value_type, value_amount, starts_at, ends_at, usage_limit, usage_count, rules_json, status. Casts: type => DiscountType, value_type => DiscountValueType, status => DiscountStatus, rules_json => array, starts_at => datetime, ends_at => datetime, value_amount => integer, usage_count => integer. Relationships: store() belongsTo(Store).

## 4. FACTORIES (database/factories/)

Create factories for ALL models above. Use sensible defaults. For Cart: currency 'USD', cart_version 1, status CartStatus::Active. For Discount: sensible defaults with starts_at = now()-1day, ends_at = now()+30days, status Active.

## 5. VALUE OBJECTS (app/ValueObjects/)

**PricingResult.php**: Readonly class with constructor: int $subtotal, int $discount, int $shipping, array $taxLines, int $taxTotal, int $total, string $currency. Add a toArray() method returning associative array.

**TaxLine.php**: Readonly class with constructor: string $name, int $rate (basis points), int $amount (cents). Add toArray() method.

**DiscountResult.php**: Readonly class with constructor: int $totalDiscount, bool $freeShipping, array $lineAllocations (keyed by line ID => discount amount). Add toArray() method.

## 6. EXCEPTIONS (app/Exceptions/)

**CartVersionMismatchException.php**: extends RuntimeException. Constructor takes int $expected, int $actual.
**InvalidDiscountException.php**: extends RuntimeException. Constructor takes string $reasonCode, string $message.
**InvalidCheckoutTransitionException.php**: extends RuntimeException.
**PaymentFailedException.php**: extends RuntimeException. Constructor takes string $errorCode, ?string $message.
**FulfillmentGuardException.php**: extends RuntimeException.

## 7. SERVICES

### app/Services/CartService.php

Methods:
- create(Store $store, ?int $customerId = null): Cart - creates cart with store currency, version 1
- addLine(Cart $cart, int $variantId, int $quantity): CartLine - validates variant active, product active, inventory check (deny policy), increments existing line or creates new, recalculates amounts, increments version
- updateLineQuantity(Cart $cart, int $lineId, int $quantity): ?CartLine - if qty=0 removes line, else updates, recalculates, increments version. Returns null if removed.
- removeLine(Cart $cart, int $lineId): void - deletes line, increments version
- getOrCreateForSession(Store $store, ?int $customerId = null): Cart - session-based cart binding using session('cart_id')
- mergeOnLogin(Cart $guestCart, Cart $customerCart): Cart - merges guest into customer, marks guest abandoned
- validateVersion(Cart $cart, int $expectedVersion): void - throws CartVersionMismatchException if mismatch

All mutations must increment cart_version. Use DB transactions.

### app/Services/DiscountService.php

Methods:
- validate(string $code, Store $store, Cart $cart): \App\Models\Discount - case-insensitive lookup, validates status=active, dates, usage limit, min purchase. Throws InvalidDiscountException with reason codes: not_found, expired, not_yet_active, usage_limit_reached, minimum_not_met
- calculate(Discount $discount, int $subtotal, array $lines): DiscountResult - calculates amount based on value_type (percent/fixed/free_shipping), allocates proportionally across qualifying lines, remainder to last line

### app/Services/ShippingCalculator.php

Methods:
- getAvailableRates(Store $store, array $address): \Illuminate\Support\Collection - matches zones by country_code then province_code, returns all active rates from matching zones
- calculate(ShippingRate $rate, Cart $cart): int - calculates cost based on rate type: flat (fixed amount), weight (sum variant weights * qty, match range), price (cart subtotal match range)

Zone matching: check countries_json for country_code, then regions_json for province_code. Best specificity wins (country+region=2, country=1). Tie-break by lowest zone ID.

### app/Services/TaxCalculator.php

Methods:
- calculate(int $amount, TaxSettings $settings, array $address): TaxLine - calculates tax using manual rate from config_json. Default rate from config_json 'default_rate' key (basis points).
- extractInclusive(int $grossAmount, int $rateBasisPoints): int - extracts tax: net = intdiv(gross * 10000, 10000 + rate), tax = gross - net
- addExclusive(int $netAmount, int $rateBasisPoints): int - adds tax: tax = intdiv(net * rate, 10000) using integer division (round via intdiv)

### app/Services/PricingEngine.php

Method: calculate(Checkout $checkout): PricingResult

Pipeline:
1. Line subtotals: unit_price * quantity per line
2. Cart subtotal: sum of line subtotals
3. Discount: if discount_code set, validate + calculate, allocate to lines
4. Discounted subtotal: subtotal - discount
5. Shipping: if shipping_method_id set, calculate via ShippingCalculator
6. Tax: on discounted amounts. If prices_include_tax, extract. Else add exclusive. Also tax shipping if configured.
7. Total: discounted_subtotal + shipping + tax_total

Store result in checkout.totals_json.

Use constructor injection for CartService, DiscountService, ShippingCalculator, TaxCalculator.

### app/Services/CheckoutService.php

State machine methods:
- createCheckout(Cart $cart): Checkout - creates checkout from cart, status=started
- setAddress(Checkout $checkout, array $data): Checkout - validates email + address fields, saves, recalculates pricing, transitions to addressed
- setShippingMethod(Checkout $checkout, int $rateId): Checkout - validates rate belongs to applicable zone, saves, recalculates, transitions to shipping_selected. If no items require shipping, skip to shipping_selected with null method and 0 shipping.
- selectPaymentMethod(Checkout $checkout, string $method): Checkout - validates, stores method, reserves inventory via InventoryService for all cart lines, sets expires_at = now+24h, transitions to payment_selected
- completeCheckout(Checkout $checkout, array $paymentDetails): mixed - IDEMPOTENT: if order exists return it. This method will be fully implemented in Phase 5 when Order model exists. For now, just transition to completed status and return the checkout.
- expireCheckout(Checkout $checkout): void - releases inventory if reserved, transitions to expired

Validate state transitions - throw InvalidCheckoutTransitionException for invalid ones.

## 8. JOBS (app/Jobs/)

**ExpireAbandonedCheckouts.php**: Artisan schedulable. Finds checkouts where status NOT IN (completed, expired) AND updated_at < now - 24 hours. For each: if status was payment_selected, release inventory. Set status = expired.

**CleanupAbandonedCarts.php**: Artisan schedulable. Finds carts where status = active AND updated_at < now - 14 days. Marks as abandoned.

Register both in routes/console.php:
```php
use Illuminate\Support\Facades\Schedule;
Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily();
```

## 9. TESTS

Create ALL these test files with ALL the test cases listed. Use Pest syntax. Use the createStoreContext() helper from tests/Pest.php.

### tests/Unit/PricingEngineTest.php (14 tests)
- calculates subtotal from line items (2 lines: 2499x2 + 7999x1 = 12997)
- calculates subtotal for a single line (1500x3 = 4500)
- returns zero subtotal for empty cart
- applies percent discount correctly (10000 subtotal, 10% = 1000 discount)
- applies fixed discount correctly (10000 subtotal, 500 fixed)
- caps fixed discount at subtotal so it never goes negative (300 subtotal, 500 fixed = 300 discount)
- applies free shipping discount by zeroing shipping
- calculates tax exclusive correctly (10000 net, 1900bp = 1900 tax)
- extracts tax from inclusive price correctly (11900 gross, 1900bp = 1900 tax, 10000 net)
- returns zero tax when rate is zero
- calculates shipping flat rate (499)
- calculates full checkout totals end to end
- handles rounding correctly with odd cent amounts
- produces identical results for identical inputs

### tests/Unit/DiscountCalculatorTest.php (13 tests)
- validates an active discount code
- rejects an expired discount code
- rejects a not-yet-active discount code
- rejects a discount that has reached its usage limit
- rejects an unknown discount code
- performs case-insensitive code lookup
- enforces minimum purchase amount rule
- passes minimum purchase when cart meets threshold
- calculates percent discount amount (15%, 10000 = 1500)
- calculates fixed discount amount
- handles free shipping discount type
- allocates discount proportionally across multiple lines
- distributes rounding remainder to the last qualifying line

### tests/Unit/TaxCalculatorTest.php (7 tests)
- calculates manual tax exclusive (10000 net, 1900bp = 1900)
- extracts manual tax from inclusive amount (11900 gross, 1900bp = 1900 tax)
- returns zero tax when no rate configured
- handles zero amount lines
- calculates tax with non-standard rate (8999, 700bp = 629)
- extracts tax correctly for small amounts (119 gross, 1900bp = 19 tax)
- handles high tax rates (10000, 2500bp = 2500)

### tests/Unit/ShippingCalculatorTest.php (9 tests)
- matches a zone by country code
- matches a zone by region code
- returns empty when no zone matches
- calculates a flat rate
- calculates a weight-based rate
- calculates a price-based rate
- returns zero shipping when no items require shipping
- returns correct rates when multiple zones match
- skips inactive rates

### tests/Unit/CartVersionTest.php (5 tests)
- starts at version 1
- increments version on add line
- increments version on update quantity
- increments version on remove line
- detects version mismatch

### tests/Feature/Cart/CartServiceTest.php (12 tests)
- creates a cart for the current store
- adds a line item to the cart
- increments quantity when adding an existing variant
- rejects add when product is not active
- rejects add when inventory is insufficient and policy is deny
- allows add when inventory is insufficient but policy is continue
- updates line quantity
- removes a line when quantity set to zero
- removes a specific line item
- increments cart version on every mutation
- returns cart via session for guest users
- merges guest cart into customer cart on login

### tests/Feature/Checkout/CheckoutFlowTest.php (5 tests)
- creates a checkout from a cart
- completes full checkout happy path (through payment_selected for now, full completion in Phase 5)
- rejects checkout for empty cart
- expires checkout after timeout
- prevents duplicate checkouts from same cart (if completed checkout exists)

### tests/Feature/Checkout/CheckoutStateTest.php (9 tests)
- transitions from started to addressed with valid address
- rejects address transition with missing required fields
- transitions from addressed to shipping_selected
- rejects shipping selection with rate from wrong zone
- skips shipping selection when no items require shipping
- transitions from shipping_selected to payment_selected
- transitions from payment_selected to completed (basic, no order creation yet)
- rejects invalid state transitions
- recalculates pricing on address change

### tests/Feature/Checkout/DiscountTest.php (6 tests)
- applies a valid percent discount code at checkout
- applies a valid fixed discount code at checkout
- removes discount when code is cleared
- rejects expired discount at checkout
- increments usage count on checkout completion (test the counter incrementing logic)
- handles free shipping discount at checkout

### tests/Feature/Checkout/ShippingTest.php (5 tests)
- returns available shipping rates for address
- returns empty when no zone matches address
- calculates flat rate correctly
- calculates weight-based rate correctly
- returns zero shipping when all items are digital

### tests/Feature/Checkout/TaxTest.php (4 tests)
- calculates exclusive tax correctly at checkout
- extracts inclusive tax correctly at checkout
- applies zero tax when no tax settings exist
- stores tax lines in totals_json

### tests/Feature/Checkout/PricingIntegrationTest.php (5 tests)
- calculates correct totals for a simple checkout
- applies discount code and recalculates
- stores pricing snapshot in totals_json
- recalculates on shipping method change
- handles prices-include-tax correctly

## EXECUTION ORDER

1. Create all enums
2. Create all migrations
3. Create all models and factories
4. Create value objects and exceptions
5. Create all services (CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService)
6. Create jobs and register in console.php
7. Create all tests
8. Run `php artisan migrate:fresh` to verify migrations
9. Run `php artisan test` to run all tests
10. Fix any failures
11. Run `vendor/bin/pint --dirty` to format

After creating everything, run the tests and fix ALL failures. Keep iterating until all tests pass.

IMPORTANT: The checkout completeCheckout method should be a stub for now (Phase 5 will add order creation). For Phase 4, just transition to completed. The test for "completes full checkout happy path" should test through to payment_selected status, not full order creation.


text: Phase 4 agent is now running in the background. Let me also update progress.md and prepare the Phase 5 spec while we wait.