On branch 2026-04-12-claude-code-team
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   app/Models/User.php
	modified:   app/Providers/AppServiceProvider.php
	modified:   bootstrap/app.php
	modified:   config/auth.php
	modified:   database/factories/UserFactory.php
	modified:   routes/web.php

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	app/Auth/
	app/Enums/
	app/Http/Middleware/
	app/Livewire/Admin/
	app/Models/Concerns/
	app/Models/Organization.php
	app/Models/Scopes/
	app/Models/Store.php
	app/Models/StoreDomain.php
	app/Models/StoreSettings.php
	app/Models/StoreUser.php
	app/Policies/
	database/factories/OrganizationFactory.php
	database/factories/StoreDomainFactory.php
	database/factories/StoreFactory.php
	database/factories/StoreSettingsFactory.php
	database/migrations/2026_04_12_100001_create_organizations_table.php
	database/migrations/2026_04_12_100002_create_stores_table.php
	database/migrations/2026_04_12_100003_create_store_domains_table.php
	database/migrations/2026_04_12_100004_modify_users_table_for_shop.php
	database/migrations/2026_04_12_100005_create_store_users_table.php
	database/migrations/2026_04_12_100006_create_store_settings_table.php
	resources/views/components/layouts/
	resources/views/livewire/admin/
	specs/progress.md
	tests/Feature/Auth/AdminAuthTest.php
	tests/Feature/Tenancy/

no changes added to commit (use "git add" and/or "git commit -a")


Bash

find /Users/fabianwesner/Herd/shop/app -name "Debug.php" 2>/dev/null

Find Debug.php


(Bash completed with no output)


Edit

- [ ] Phase 1: Foundation (migrations, models, middleware, auth, authorization)

- [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing


The file /Users/fabianwesner/Herd/shop/specs/progress.md has been updated successfully.

# Shop Build Progress

Tracking progress for the full shop system implementation (team: shop-v2).

## Phase Checklist

- [ ] Phase 1: Foundation (migrations, models, middleware, auth, authorization)
- [ ] Phase 2: Catalog (products, variants, inventory, collections, media)
- [ ] Phase 3: Themes, pages, navigation, storefront layout
- [ ] Phase 4: Cart, checkout, discounts, shipping, taxes
- [ ] Phase 5: Payments, orders, fulfillment
- [ ] Phase 6: Customer accounts
- [ ] Phase 7: Admin panel
- [ ] Phase 8: Search
- [ ] Phase 9: Analytics
- [ ] Phase 10: Apps and webhooks
- [ ] Phase 11: Polish
- [ ] Phase 12: Full test suite execution + browser review

## Log

- Starting Phase 1: Foundation

-- [ ] Phase 1: Foundation (migrations, models, middleware, auth, authorization)

+- [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing


Bash

git add app/ database/ bootstrap/ config/ routes/ resources/ specs/ tests/ && git commit -m "$(cat <<'EOF'
Phase 1: Foundation (tenancy, auth, core models)

Implements the multi-tenant foundation for the shop platform:
- Migrations: organizations, stores, store_domains, store_users, store_settings, users mods
- Core models: Organization, Store, StoreDomain, StoreUser (pivot), StoreSettings
- Enums: StoreStatus, StoreUserRole, StoreDomainType
- BelongsToStore trait + StoreScope for tenant isolation
- ResolveStore middleware (hostname and admin session variants)
- Admin Login Livewire component with rate limiting
- CustomerUserProvider (customer guard) registered for Phase 6
- StorePolicy + ChecksStoreRole trait for authorization
- Tests: TenantResolutionTest, StoreIsolationTest, AdminAuthTest (42 pass)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"

Commit Phase 1


[2026-04-12-claude-code-team f6d08a65] Phase 1: Foundation (tenancy, auth, core models)
 37 files changed, 1368 insertions(+), 5 deletions(-)
 create mode 100644 app/Auth/CustomerUserProvider.php
 create mode 100644 app/Enums/StoreDomainType.php
 create mode 100644 app/Enums/StoreStatus.php
 create mode 100644 app/Enums/StoreUserRole.php
 create mode 100644 app/Http/Middleware/ResolveStore.php
 create mode 100644 app/Livewire/Admin/Auth/Login.php
 create mode 100644 app/Models/Concerns/BelongsToStore.php
 create mode 100644 app/Models/Organization.php
 create mode 100644 app/Models/Scopes/StoreScope.php
 create mode 100644 app/Models/Store.php
 create mode 100644 app/Models/StoreDomain.php
 create mode 100644 app/Models/StoreSettings.php
 create mode 100644 app/Models/StoreUser.php
 create mode 100644 app/Policies/Concerns/ChecksStoreRole.php
 create mode 100644 app/Policies/StorePolicy.php
 create mode 100644 database/factories/OrganizationFactory.php
 create mode 100644 database/factories/StoreDomainFactory.php
 create mode 100644 database/factories/StoreFactory.php
 create mode 100644 database/factories/StoreSettingsFactory.php
 create mode 100644 database/migrations/2026_04_12_100001_create_organizations_table.php
 create mode 100644 database/migrations/2026_04_12_100002_create_stores_table.php
 create mode 100644 database/migrations/2026_04_12_100003_create_store_domains_table.php
 create mode 100644 database/migrations/2026_04_12_100004_modify_users_table_for_shop.php
 create mode 100644 database/migrations/2026_04_12_100005_create_store_users_table.php
 create mode 100644 database/migrations/2026_04_12_100006_create_store_settings_table.php
 create mode 100644 resources/views/components/layouts/admin-auth.blade.php
 create mode 100644 resources/views/livewire/admin/auth/login.blade.php
 create mode 100644 specs/progress.md
 create mode 100644 tests/Feature/Auth/AdminAuthTest.php
 create mode 100644 tests/Feature/Tenancy/StoreIsolationTest.php
 create mode 100644 tests/Feature/Tenancy/TenantResolutionTest.php


Phase 1: Foundation

Implement Phase 1 per specs/09-IMPLEMENTATION-ROADMAP.md: env/config, core migrations (organizations, stores, store_domains, users modifications, store_users, store_settings), core models with factories, enums (StoreStatus, StoreUserRole, StoreDomainType), ResolveStore middleware, BelongsToStore trait + StoreScope, admin auth (Login Livewire component), customer auth (custom guard + CustomerUserProvider), authorization policies, Pest tests for tenancy/auth.

Phase 1 Foundation build

Build Phase 1 of multi-tenant shop: env config, migrations, enums, models, factories, trait/scope, middleware, admin auth, customer auth provider, policies, routes, and tests.


<command-name>/sandbox</command-name>
            <command-message>sandbox</command-message>
            <command-args></command-args>


<local-command-stdout>○ Sandbox disabled</local-command-stdout>


Updated task #1 status


Updated task #2 deleted


TaskCreate

Phase 2: Catalog

Implement Phase 2: products, product options/values, variants, inventory_items, collections, collection_products, product_media migrations/models/factories. Services: ProductService, VariantMatrixService, InventoryService, HandleGenerator. Job: ProcessMediaUpload. Enums: ProductStatus, VariantStatus, CollectionStatus, MediaType, MediaStatus, InventoryPolicy. Pest tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest.


Task #3 created successfully: Phase 2: Catalog

Phase 2: Catalog


Updated task #3 owner, status


Agent

Phase 2 Catalog implementer

Prompt:
You are a Laravel 12 backend engineer building Phase 2 (Catalog) for the shop at /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete and committed. Work autonomously until Phase 2 is done and tests pass.

## CRITICAL RULES
1. NEVER use the em-dash character (—)
2. Explicit PHP 8.4 return types + type hints everywhere
3. Use `php artisan make:*` for scaffolding
4. Run `vendor/bin/pint --dirty --format agent` before reporting done
5. Monetary amounts as INTEGER minor units (cents)
6. Use `fake()` helper
7. Apply `BelongsToStore` trait (already exists) to all store-scoped models: Product, Collection, InventoryItem
8. Check file existence with Read before editing
9. Do NOT commit - team-lead commits

## Phase 1 Artifacts (already available)
- Enums: `App\Enums\StoreStatus`, `StoreUserRole`, `StoreDomainType`
- Models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User
- Trait: `App\Models\Concerns\BelongsToStore`
- Scope: `App\Models\Scopes\StoreScope`
- Middleware: `ResolveStore` (alias: store.resolve)

## Phase 2 Scope

### Step 1: Read schema spec
Read `specs/01-DATABASE-SCHEMA.md` lines 400-900 (or find section on products/variants/collections/inventory/media) to get exact column definitions and constraints. Use Grep to locate "products" table definition.

### Step 2: Enums (app/Enums)
Create (string-backed):
- `ProductStatus`: Draft='draft', Active='active', Archived='archived'
- `VariantStatus`: Active='active', Archived='archived'
- `CollectionStatus`: Draft='draft', Active='active', Archived='archived'
- `CollectionType`: Manual='manual', Smart='smart'
- `MediaType`: Image='image', Video='video'
- `MediaStatus`: Processing='processing', Ready='ready', Failed='failed'
- `InventoryPolicy`: Deny='deny', Continue='continue'

### Step 3: Migrations
Create migrations (timestamp prefix `2026_04_12_1010NN_`) in this dependency order:
1. `create_products_table`: id, store_id (FK), title, handle, status (text+check), description_html (nullable), vendor (nullable), product_type (nullable), tags (json/text), published_at (timestamp null), created_at, updated_at. Composite unique (store_id, handle).
2. `create_product_options_table`: id, product_id (FK), name, position. No timestamps.
3. `create_product_option_values_table`: id, product_option_id (FK), value, position. No timestamps.
4. `create_product_variants_table`: id, product_id (FK), sku (nullable), barcode (nullable), price_amount (int), compare_at_amount (int null), currency (3), weight_g (int null), requires_shipping (bool default 1), is_default (bool default 0), position (int), status (text+check), created_at, updated_at.
5. `create_variant_option_values_table`: variant_id, product_option_value_id (composite PK). No timestamps.
6. `create_inventory_items_table`: id, store_id (FK), variant_id (unique FK), quantity_on_hand (int), quantity_reserved (int default 0), policy (text+check default 'deny'), updated_at. No created_at.
7. `create_collections_table`: id, store_id (FK), title, handle, description_html (null), type (text+check default 'manual'), status (text+check default 'active'), created_at, updated_at. Composite unique (store_id, handle).
8. `create_collection_products_table`: collection_id, product_id (composite PK), position (int default 0). No timestamps.
9. `create_product_media_table`: id, product_id (FK), type (text+check), storage_key, alt_text (null), width (int null), height (int null), mime_type (null), byte_size (int null), position (int), status (text+check default 'processing'), created_at. No updated_at.

Use CREATE TRIGGER for CHECK constraints (same pattern Phase 1 used for stores.status). See existing Phase 1 migrations for the trigger syntax.

After creating migrations, run `php artisan migrate:fresh` to verify schema compiles.

### Step 4: Models + Factories
Use `php artisan make:model Name -f` for each.

**Product** (uses BelongsToStore):
- fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at
- casts: status=>ProductStatus, tags=>array, published_at=>datetime
- Relations: hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection, 'collection_products')->withPivot('position')
- Factory defaults: realistic product data; states: draft(), archived()

**ProductOption**:
- fillable: product_id, name, position
- $timestamps = false
- Relations: belongsTo(Product), hasMany(ProductOptionValue)

**ProductOptionValue**:
- fillable: product_option_id, value, position
- $timestamps = false
- Relations: belongsTo(ProductOption)

**ProductVariant**:
- fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status
- casts: status=>VariantStatus, requires_shipping=>bool, is_default=>bool
- Relations: belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue, 'variant_option_values', 'variant_id', 'product_option_value_id')
- Factory: price 2499, currency 'EUR', is_default false, status 'active'

**InventoryItem** (uses BelongsToStore):
- fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy
- casts: policy=>InventoryPolicy
- $timestamps = false (only updated_at column is managed by model)
- Actually this has only updated_at, so: const CREATED_AT = null
- Relations: belongsTo(ProductVariant, 'variant_id')
- Helper: `quantityAvailable(): int` returns on_hand - reserved

**Collection** (uses BelongsToStore):
- fillable: store_id, title, handle, description_html, type, status
- casts: type=>CollectionType, status=>CollectionStatus
- Relations: belongsToMany(Product, 'collection_products')->withPivot('position')

**ProductMedia**:
- fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status
- casts: type=>MediaType, status=>MediaStatus
- const UPDATED_AT = null
- Relations: belongsTo(Product)

Factories should chain relationships properly (Product::factory()->for(Store::factory())).

### Step 5: Services

**`App\Support\HandleGenerator`**:
```php
public static function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
```
- Uses Str::slug()
- Checks uniqueness via DB query with store_id + optional excludeId
- Appends -2, -3, etc. until unique

**`App\Services\ProductService`**:
- `create(Store $store, array $data): Product` - creates product + nested variants/options in a DB transaction. Uses HandleGenerator.
- `update(Product $product, array $data): Product` - updates, rebuilds variants if options changed
- `transitionStatus(Product $product, ProductStatus $newStatus): void` - validates transitions (draft->active, active->archived, archived->active OK; active->draft NOT OK)
- `delete(Product $product): void` - deletes only if draft AND no order_lines reference it (order_lines table doesn't exist yet in Phase 2, so skip the order check with a comment about Phase 5)

**`App\Services\VariantMatrixService`**:
- `rebuildMatrix(Product $product): void` - calculates cartesian product of option values, creates missing variants (default price 0, status active), archives orphaned ones.

**`App\Services\InventoryService`**:
- `checkAvailability(InventoryItem $item, int $quantity): bool`
- `reserve(InventoryItem $item, int $quantity): void` - increments quantity_reserved, throws `InsufficientInventoryException` if policy=deny and available < quantity
- `release(InventoryItem $item, int $quantity): void` - decrements quantity_reserved
- `commit(InventoryItem $item, int $quantity): void` - decrements both on_hand and reserved
- `restock(InventoryItem $item, int $quantity): void` - increments on_hand
- All operations wrapped in DB transaction

Create `App\Exceptions\InsufficientInventoryException extends RuntimeException`.

### Step 6: Media Job
Create `App\Jobs\ProcessMediaUpload implements ShouldQueue`:
- Constructor takes ProductMedia
- handle(): stub implementation. Would resize image to thumbnail 150x150 etc. For now, just marks status as 'ready' since we have no image processing library. Log that this is a stub.

### Step 7: Pest Tests (tests/Feature/Products/ and tests/Feature/Collections/)
All use RefreshDatabase. Use ProductService and InventoryService directly. For tenant scoping, bind a Store to 'current_store' in beforeEach.

Create:

**tests/Feature/Products/ProductCrudTest.php**:
- it creates a product with variants via ProductService
- it generates unique handles when title collides
- it transitions status from draft to active
- it rejects invalid status transitions
- it prevents deletion of active products (for now, allow drafts only)
- it scopes products to the current store (create product in store A, verify not visible when current_store is B)

**tests/Feature/Products/VariantTest.php**:
- it builds a variant matrix from product options
- it archives orphaned variants when options change
- it stores prices as integers in minor units

**tests/Feature/Products/InventoryTest.php**:
- it reserves inventory when quantity available
- it throws InsufficientInventoryException when policy is deny and insufficient
- it allows over-selling when policy is continue
- it releases reserved inventory
- it commits inventory on order completion (decrements both)
- it restocks inventory

**tests/Feature/Products/CollectionTest.php**:
- it creates a manual collection with products
- it maintains product position in collection
- it scopes collections to current store

**tests/Feature/Products/MediaUploadTest.php**:
- it creates product media records
- it marks media as ready after processing (stub job)
Set a fake storage disk with `Storage::fake('public')` where relevant.

### Execution Order
1. Read relevant schema spec sections
2. Create enums
3. Create migrations
4. Run `php artisan migrate:fresh`
5. Create models + factories
6. Create HandleGenerator, ProductService, VariantMatrixService, InventoryService, InsufficientInventoryException
7. Create ProcessMediaUpload job
8. Write tests
9. Run `php artisan test --compact` until green
10. Run `vendor/bin/pint --dirty --format agent`
11. Run `php artisan migrate:fresh` to verify
12. Report back with summary

## Reporting
Send message to team-lead with:
- Test results summary
- Files created count
- Any decisions / spec ambiguities
- Blockers

Mark task #3 completed when Phase 2 tests are green.

Start now. Work autonomously and resolve errors at their root.


Spawned successfully.
agent_id: phase2-catalog@shop-v2
name: phase2-catalog
team_name: shop-v2
The agent is now running and will receive instructions via mailbox.

Prompt:
You are a Laravel 12 backend engineer building Phase 2 (Catalog) for the shop at /Users/fabianwesner/Herd/shop. Phase 1 (Foundation) is complete and committed. Work autonomously until Phase 2 is done and tests pass.

## CRITICAL RULES
1. NEVER use the em-dash character (—)
2. Explicit PHP 8.4 return types + type hints everywhere
3. Use `php artisan make:*` for scaffolding
4. Run `vendor/bin/pint --dirty --format agent` before reporting done
5. Monetary amounts as INTEGER minor units (cents)
6. Use `fake()` helper
7. Apply `BelongsToStore` trait (already exists) to all store-scoped models: Product, Collection, InventoryItem
8. Check file existence with Read before editing
9. Do NOT commit - team-lead commits

## Phase 1 Artifacts (already available)
- Enums: `App\Enums\StoreStatus`, `StoreUserRole`, `StoreDomainType`
- Models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User
- Trait: `App\Models\Concerns\BelongsToStore`
- Scope: `App\Models\Scopes\StoreScope`
- Middleware: `ResolveStore` (alias: store.resolve)

## Phase 2 Scope

### Step 1: Read schema spec
Read `specs/01-DATABASE-SCHEMA.md` lines 400-900 (or find section on products/variants/collections/inventory/media) to get exact column definitions and constraints. Use Grep to locate "products" table definition.

### Step 2: Enums (app/Enums)
Create (string-backed):
- `ProductStatus`: Draft='draft', Active='active', Archived='archived'
- `VariantStatus`: Active='active', Archived='archived'
- `CollectionStatus`: Draft='draft', Active='active', Archived='archived'
- `CollectionType`: Manual='manual', Smart='smart'
- `MediaType`: Image='image', Video='video'
- `MediaStatus`: Processing='processing', Ready='ready', Failed='failed'
- `InventoryPolicy`: Deny='deny', Continue='continue'

### Step 3: Migrations
Create migrations (timestamp prefix `2026_04_12_1010NN_`) in this dependency order:
1. `create_products_table`: id, store_id (FK), title, handle, status (text+check), description_html (nullable), vendor (nullable), product_type (nullable), tags (json/text), published_at (timestamp null), created_at, updated_at. Composite unique (store_id, handle).
2. `create_product_options_table`: id, product_id (FK), name, position. No timestamps.
3. `create_product_option_values_table`: id, product_option_id (FK), value, position. No timestamps.
4. `create_product_variants_table`: id, product_id (FK), sku (nullable), barcode (nullable), price_amount (int), compare_at_amount (int null), currency (3), weight_g (int null), requires_shipping (bool default 1), is_default (bool default 0), position (int), status (text+check), created_at, updated_at.
5. `create_variant_option_values_table`: variant_id, product_option_value_id (composite PK). No timestamps.
6. `create_inventory_items_table`: id, store_id (FK), variant_id (unique FK), quantity_on_hand (int), quantity_reserved (int default 0), policy (text+check default 'deny'), updated_at. No created_at.
7. `create_collections_table`: id, store_id (FK), title, handle, description_html (null), type (text+check default 'manual'), status (text+check default 'active'), created_at, updated_at. Composite unique (store_id, handle).
8. `create_collection_products_table`: collection_id, product_id (composite PK), position (int default 0). No timestamps.
9. `create_product_media_table`: id, product_id (FK), type (text+check), storage_key, alt_text (null), width (int null), height (int null), mime_type (null), byte_size (int null), position (int), status (text+check default 'processing'), created_at. No updated_at.

Use CREATE TRIGGER for CHECK constraints (same pattern Phase 1 used for stores.status). See existing Phase 1 migrations for the trigger syntax.

After creating migrations, run `php artisan migrate:fresh` to verify schema compiles.

### Step 4: Models + Factories
Use `php artisan make:model Name -f` for each.

**Product** (uses BelongsToStore):
- fillable: store_id, title, handle, status, description_html, vendor, product_type, tags, published_at
- casts: status=>ProductStatus, tags=>array, published_at=>datetime
- Relations: hasMany(ProductVariant), hasMany(ProductOption), hasMany(ProductMedia), belongsToMany(Collection, 'collection_products')->withPivot('position')
- Factory defaults: realistic product data; states: draft(), archived()

**ProductOption**:
- fillable: product_id, name, position
- $timestamps = false
- Relations: belongsTo(Product), hasMany(ProductOptionValue)

**ProductOptionValue**:
- fillable: product_option_id, value, position
- $timestamps = false
- Relations: belongsTo(ProductOption)

**ProductVariant**:
- fillable: product_id, sku, barcode, price_amount, compare_at_amount, currency, weight_g, requires_shipping, is_default, position, status
- casts: status=>VariantStatus, requires_shipping=>bool, is_default=>bool
- Relations: belongsTo(Product), hasOne(InventoryItem, 'variant_id'), belongsToMany(ProductOptionValue, 'variant_option_values', 'variant_id', 'product_option_value_id')
- Factory: price 2499, currency 'EUR', is_default false, status 'active'

**InventoryItem** (uses BelongsToStore):
- fillable: store_id, variant_id, quantity_on_hand, quantity_reserved, policy
- casts: policy=>InventoryPolicy
- $timestamps = false (only updated_at column is managed by model)
- Actually this has only updated_at, so: const CREATED_AT = null
- Relations: belongsTo(ProductVariant, 'variant_id')
- Helper: `quantityAvailable(): int` returns on_hand - reserved

**Collection** (uses BelongsToStore):
- fillable: store_id, title, handle, description_html, type, status
- casts: type=>CollectionType, status=>CollectionStatus
- Relations: belongsToMany(Product, 'collection_products')->withPivot('position')

**ProductMedia**:
- fillable: product_id, type, storage_key, alt_text, width, height, mime_type, byte_size, position, status
- casts: type=>MediaType, status=>MediaStatus
- const UPDATED_AT = null
- Relations: belongsTo(Product)

Factories should chain relationships properly (Product::factory()->for(Store::factory())).

### Step 5: Services

**`App\Support\HandleGenerator`**:
```php
public static function generate(string $title, string $table, int $storeId, ?int $excludeId = null): string
```
- Uses Str::slug()
- Checks uniqueness via DB query with store_id + optional excludeId
- Appends -2, -3, etc. until unique

**`App\Services\ProductService`**:
- `create(Store $store, array $data): Product` - creates product + nested variants/options in a DB transaction. Uses HandleGenerator.
- `update(Product $product, array $data): Product` - updates, rebuilds variants if options changed
- `transitionStatus(Product $product, ProductStatus $newStatus): void` - validates transitions (draft->active, active->archived, archived->active OK; active->draft NOT OK)
- `delete(Product $product): void` - deletes only if draft AND no order_lines reference it (order_lines table doesn't exist yet in Phase 2, so skip the order check with a comment about Phase 5)

**`App\Services\VariantMatrixService`**:
- `rebuildMatrix(Product $product): void` - calculates cartesian product of option values, creates missing variants (default price 0, status active), archives orphaned ones.

**`App\Services\InventoryService`**:
- `checkAvailability(InventoryItem $item, int $quantity): bool`
- `reserve(InventoryItem $item, int $quantity): void` - increments quantity_reserved, throws `InsufficientInventoryException` if policy=deny and available < quantity
- `release(InventoryItem $item, int $quantity): void` - decrements quantity_reserved
- `commit(InventoryItem $item, int $quantity): void` - decrements both on_hand and reserved
- `restock(InventoryItem $item, int $quantity): void` - increments on_hand
- All operations wrapped in DB transaction

Create `App\Exceptions\InsufficientInventoryException extends RuntimeException`.

### Step 6: Media Job
Create `App\Jobs\ProcessMediaUpload implements ShouldQueue`:
- Constructor takes ProductMedia
- handle(): stub implementation. Would resize image to thumbnail 150x150 etc. For now, just marks status as 'ready' since we have no image processing library. Log that this is a stub.

### Step 7: Pest Tests (tests/Feature/Products/ and tests/Feature/Collections/)
All use RefreshDatabase. Use ProductService and InventoryService directly. For tenant scoping, bind a Store to 'current_store' in beforeEach.

Create:

**tests/Feature/Products/ProductCrudTest.php**:
- it creates a product with variants via ProductService
- it generates unique handles when title collides
- it transitions status from draft to active
- it rejects invalid status transitions
- it prevents deletion of active products (for now, allow drafts only)
- it scopes products to the current store (create product in store A, verify not visible when current_store is B)

**tests/Feature/Products/VariantTest.php**:
- it builds a variant matrix from product options
- it archives orphaned variants when options change
- it stores prices as integers in minor units

**tests/Feature/Products/InventoryTest.php**:
- it reserves inventory when quantity available
- it throws InsufficientInventoryException when policy is deny and insufficient
- it allows over-selling when policy is continue
- it releases reserved inventory
- it commits inventory on order completion (decrements both)
- it restocks inventory

**tests/Feature/Products/CollectionTest.php**:
- it creates a manual collection with products
- it maintains product position in collection
- it scopes collections to current store

**tests/Feature/Products/MediaUploadTest.php**:
- it creates product media records
- it marks media as ready after processing (stub job)
Set a fake storage disk with `Storage::fake('public')` where relevant.

### Execution Order
1. Read relevant schema spec sections
2. Create enums
3. Create migrations
4. Run `php artisan migrate:fresh`
5. Create models + factories
6. Create HandleGenerator, ProductService, VariantMatrixService, InventoryService, InsufficientInventoryException
7. Create ProcessMediaUpload job
8. Write tests
9. Run `php artisan test --compact` until green
10. Run `vendor/bin/pint --dirty --format agent`
11. Run `php artisan migrate:fresh` to verify
12. Report back with summary

## Reporting
Send message to team-lead with:
- Test results summary
- Files created count
- Any decisions / spec ambiguities
- Blockers

Mark task #3 completed when Phase 2 tests are green.

Start now. Work autonomously and resolve errors at their root.


Phase 2 worker dispatched. Awaiting completion.


<teammate-message teammate_id="phase2-catalog" color="green" summary="Phase 2 Catalog complete; 21 new tests pass">
Phase 2 (Catalog) complete. Task #3 marked completed.

Test results: 63 passed / 143 assertions (21 new Phase 2 tests + 42 Phase 1 tests, zero regressions).

Files created (39 total):
- 7 enums in /Users/fabianwesner/Herd/shop/app/Enums/: ProductStatus, VariantStatus, CollectionStatus, CollectionType, MediaType, MediaStatus, InventoryPolicy
- 9 migrations in /Users/fabianwesner/Herd/shop/database/migrations/ (prefixed 2026_04_12_10100[1-9]): products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media. CHECK constraints implemented via SQLite triggers following Phase 1 pattern.
- 7 models in /Users/fabianwesner/Herd/shop/app/Models/: Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia. BelongsToStore trait applied to Product, Collection, InventoryItem.
- 7 factories in /Users/fabianwesner/Herd/shop/database/factories/
- 4 services/support in /Users/fabianwesner/Herd/shop/app/: Support/HandleGenerator.php, Services/ProductService.php, Services/VariantMatrixService.php, Services/InventoryService.php
- 1 exception: /Users/fabianwesner/Herd/shop/app/Exceptions/InsufficientInventoryException.php
- 1 job: /Users/fabianwesner/Herd/shop/app/Jobs/ProcessMediaUpload.php (stub - marks media Ready, logs intent)
- 5 test files in /Users/fabianwesner/Herd/shop/tests/Feature/Products/: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest

Spec decisions/ambiguities:
1. Collection type enum: spec says CHECK IN ('manual','automated'), not 'smart'. Used CollectionType::Automated = 'automated' per spec, overriding the task directive which said 'smart'. Please confirm.
2. Product deletion: since order_lines does not exist until Phase 5, ProductService::delete only validates draft status. Added a comment referencing Phase 5 for the order check.
3. Cart tests in tests/Feature/Products/ per task directive (no tests/Feature/Collections/ directory was actually used — all catalog tests live under Products/).
4. ProductService::syncOptions reconciles options/values by name rather than delete+recreate, so preserved option_value IDs survive variant_option_values cascades. Positions are offset by +1000 during update to avoid unique (product_option_id, position) collisions mid-reconcile.
5. VariantMatrixService keys variants by option_value IDs; archives orphaned variants whose key is no longer part of the cartesian product. Discovered and fixed a PHP array-key int-coercion bug where string keys in existingByKey became ints, breaking strict in_array match against string seenKeys.

Pint passed (vendor/bin/pint --dirty --format agent ran clean). Final migrate:fresh works. No commits were made per directive.
</teammate-message>

<teammate-message teammate_id="phase2-catalog" color="green">
{"type":"idle_notification","from":"phase2-catalog","timestamp":"2026-04-12T18:40:09.868Z","idleReason":"available"}
</teammate-message>


Bash

php artisan test --compact 2>&1 | tail -10 && echo "---" && git status --short | head -50

Verify tests and git status


  ...............................................................

  Tests:    63 passed (143 assertions)
  Duration: 2.99s

---
 M .claude/settings.local.json
?? app/Enums/CollectionStatus.php
?? app/Enums/CollectionType.php
?? app/Enums/InventoryPolicy.php
?? app/Enums/MediaStatus.php
?? app/Enums/MediaType.php
?? app/Enums/ProductStatus.php
?? app/Enums/VariantStatus.php
?? app/Exceptions/
?? app/Jobs/
?? app/Models/Collection.php
?? app/Models/InventoryItem.php
?? app/Models/Product.php
?? app/Models/ProductMedia.php
?? app/Models/ProductOption.php
?? app/Models/ProductOptionValue.php
?? app/Models/ProductVariant.php
?? app/Services/
?? app/Support/
?? database/factories/CollectionFactory.php
?? database/factories/InventoryItemFactory.php
?? database/factories/ProductFactory.php
?? database/factories/ProductMediaFactory.php
?? database/factories/ProductOptionFactory.php
?? database/factories/ProductOptionValueFactory.php
?? database/factories/ProductVariantFactory.php
?? database/migrations/2026_04_12_101001_create_products_table.php
?? database/migrations/2026_04_12_101002_create_product_options_table.php
?? database/migrations/2026_04_12_101003_create_product_option_values_table.php
?? database/migrations/2026_04_12_101004_create_product_variants_table.php
?? database/migrations/2026_04_12_101005_create_variant_option_values_table.php
?? database/migrations/2026_04_12_101006_create_inventory_items_table.php
?? database/migrations/2026_04_12_101007_create_collections_table.php
?? database/migrations/2026_04_12_101008_create_collection_products_table.php
?? database/migrations/2026_04_12_101009_create_product_media_table.php
?? tests/Feature/Products/


Undefined type 'App\Models\ProductVariant'.

Undefined type 'App\Models\ProductOption'.

Undefined type 'App\Models\ProductMedia'.

Undefined type 'App\Models\Collection'.

Undefined type 'App\Models\ProductOptionValue'.

Undefined type 'App\Models\InventoryItem'.

Symbol '$attributes' is declared but not used.

Undefined type 'App\Services\VariantMatrixService'.

Symbol 'App\Services\ProductVariant' is declared but not used.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$service'.


Edit

- [ ] Phase 2: Catalog (products, variants, inventory, collections, media)

- [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing


# Shop Build Progress

Tracking progress for the full shop system implementation (team: shop-v2).

## Phase Checklist

- [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing
- [ ] Phase 2: Catalog (products, variants, inventory, collections, media)
- [ ] Phase 3: Themes, pages, navigation, storefront layout
- [ ] Phase 4: Cart, checkout, discounts, shipping, taxes
- [ ] Phase 5: Payments, orders, fulfillment
- [ ] Phase 6: Customer accounts
- [ ] Phase 7: Admin panel
- [ ] Phase 8: Search
- [ ] Phase 9: Analytics
- [ ] Phase 10: Apps and webhooks
- [ ] Phase 11: Polish
- [ ] Phase 12: Full test suite execution + browser review

## Log

- Starting Phase 1: Foundation

-- [ ] Phase 2: Catalog (products, variants, inventory, collections, media)

+- [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing


Bash

git add app/ database/ tests/ specs/ && git commit -m "$(cat <<'EOF'
Phase 2: Catalog (products, variants, inventory, collections, media)

- Migrations: products, product_options/values, product_variants,
  variant_option_values, inventory_items, collections,
  collection_products, product_media
- Models + factories for all 7 catalog entities with BelongsToStore
  applied to Product, Collection, InventoryItem
- Enums: ProductStatus, VariantStatus, CollectionStatus, CollectionType,
  MediaType, MediaStatus, InventoryPolicy
- Services: ProductService (CRUD with transaction), VariantMatrixService
  (cartesian rebuild with orphan archival), InventoryService (reserve,
  release, commit, restock), HandleGenerator
- Job: ProcessMediaUpload (stub, marks media Ready)
- Exception: InsufficientInventoryException
- Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest,
  MediaUploadTest (21 new tests, 63 total passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"

Commit Phase 2


[2026-04-12-claude-code-team 3c2a4172] Phase 2: Catalog (products, variants, inventory, collections, media)
 42 files changed, 1832 insertions(+), 1 deletion(-)
 create mode 100644 app/Enums/CollectionStatus.php
 create mode 100644 app/Enums/CollectionType.php
 create mode 100644 app/Enums/InventoryPolicy.php
 create mode 100644 app/Enums/MediaStatus.php
 create mode 100644 app/Enums/MediaType.php
 create mode 100644 app/Enums/ProductStatus.php
 create mode 100644 app/Enums/VariantStatus.php
 create mode 100644 app/Exceptions/InsufficientInventoryException.php
 create mode 100644 app/Jobs/ProcessMediaUpload.php
 create mode 100644 app/Models/Collection.php
 create mode 100644 app/Models/InventoryItem.php
 create mode 100644 app/Models/Product.php
 create mode 100644 app/Models/ProductMedia.php
 create mode 100644 app/Models/ProductOption.php
 create mode 100644 app/Models/ProductOptionValue.php
 create mode 100644 app/Models/ProductVariant.php
 create mode 100644 app/Services/InventoryService.php
 create mode 100644 app/Services/ProductService.php
 create mode 100644 app/Services/VariantMatrixService.php
 create mode 100644 app/Support/HandleGenerator.php
 create mode 100644 database/factories/CollectionFactory.php
 create mode 100644 database/factories/InventoryItemFactory.php
 create mode 100644 database/factories/ProductFactory.php
 create mode 100644 database/factories/ProductMediaFactory.php
 create mode 100644 database/factories/ProductOptionFactory.php
 create mode 100644 database/factories/ProductOptionValueFactory.php
 create mode 100644 database/factories/ProductVariantFactory.php
 create mode 100644 database/migrations/2026_04_12_101001_create_products_table.php
 create mode 100644 database/migrations/2026_04_12_101002_create_product_options_table.php
 create mode 100644 database/migrations/2026_04_12_101003_create_product_option_values_table.php
 create mode 100644 database/migrations/2026_04_12_101004_create_product_variants_table.php
 create mode 100644 database/migrations/2026_04_12_101005_create_variant_option_values_table.php
 create mode 100644 database/migrations/2026_04_12_101006_create_inventory_items_table.php
 create mode 100644 database/migrations/2026_04_12_101007_create_collections_table.php
 create mode 100644 database/migrations/2026_04_12_101008_create_collection_products_table.php
 create mode 100644 database/migrations/2026_04_12_101009_create_product_media_table.php
 create mode 100644 tests/Feature/Products/CollectionTest.php
 create mode 100644 tests/Feature/Products/InventoryTest.php
 create mode 100644 tests/Feature/Products/MediaUploadTest.php
 create mode 100644 tests/Feature/Products/ProductCrudTest.php
 create mode 100644 tests/Feature/Products/VariantTest.php


Task not found


No tasks found


TaskCreate

Phase 3: Themes, Pages, Nav, Storefront Layout

Implement Phase 3: themes/theme_files/theme_settings, pages, navigation_menus/items migrations/models/factories. Services: ThemeSettingsService, NavigationService. Storefront base layout (app.blade.php with header, nav, footer, cart drawer slot). Enums: ThemeStatus, PageStatus, NavigationItemType. Pest tests for theme/page/navigation.

Building Phase 3 Themes and Storefront Layout


Task #4 created successfully: Phase 3: Themes, Pages, Nav, Storefront Layout


Spawned successfully.
agent_id: phase3-themes@shop-v2
name: phase3-themes
team_name: shop-v2
The agent is now running and will receive instructions via mailbox.

Prompt:
You are a Laravel 12 backend engineer building Phase 3 (Themes, Pages, Navigation, Storefront Layout Scaffold) for the shop at /Users/fabianwesner/Herd/shop. Phases 1 and 2 are complete and committed. Work autonomously until Phase 3 tests pass.

## CRITICAL RULES
1. NEVER use the em-dash character (—)
2. Explicit PHP 8.4 return types + type hints
3. Use `php artisan make:*` for scaffolding
4. Run `vendor/bin/pint --dirty --format agent` before reporting done
5. Use `fake()` helper
6. Apply BelongsToStore trait to Theme, Page, NavigationMenu
7. DO NOT commit

## Phase 1+2 Artifacts
All enums, models from previous phases exist. Trait: `App\Models\Concerns\BelongsToStore`.

## Phase 3 Scope

### Step 1: Read Schema
Grep `specs/01-DATABASE-SCHEMA.md` for "themes" table to find column specs for themes, theme_files, theme_settings, pages, navigation_menus, navigation_items. Read the relevant sections.

### Step 2: Enums
Create in `app/Enums/`:
- `ThemeStatus`: Draft='draft', Published='published'
- `PageStatus`: Draft='draft', Published='published', Archived='archived'
- `NavigationItemType`: Link='link', Page='page', Collection='collection', Product='product'

### Step 3: Migrations (prefix 2026_04_12_1020NN)
1. `create_themes_table`: id, store_id FK, name, version, status (check), published_at nullable, timestamps
2. `create_theme_files_table`: id, theme_id FK, path, storage_key, sha256, byte_size. No timestamps (or only created_at per spec)
3. `create_theme_settings_table`: theme_id PK, settings_json, updated_at
4. `create_pages_table`: id, store_id FK, title, handle, body_html nullable, status (check), published_at nullable, timestamps. Unique (store_id, handle)
5. `create_navigation_menus_table`: id, store_id FK, handle, title, timestamps. Unique (store_id, handle)
6. `create_navigation_items_table`: id, menu_id FK, type (check), label, url nullable, resource_id nullable (BIGINT), position. No timestamps

Use triggers for CHECK constraints (Phase 1/2 pattern).

Run `php artisan migrate:fresh` after creating.

### Step 4: Models + Factories

**Theme** (BelongsToStore):
- fillable: store_id, name, version, status, published_at
- casts: status=>ThemeStatus, published_at=>datetime
- Relations: hasMany(ThemeFile), hasOne(ThemeSettings)

**ThemeFile**:
- fillable: theme_id, path, storage_key, sha256, byte_size
- $timestamps = false
- Relations: belongsTo(Theme)

**ThemeSettings**:
- $primaryKey = 'theme_id', $incrementing = false
- fillable: theme_id, settings_json
- casts: settings_json=>array
- const CREATED_AT = null
- Relations: belongsTo(Theme)

**Page** (BelongsToStore):
- fillable: store_id, title, handle, body_html, status, published_at
- casts: status=>PageStatus, published_at=>datetime

**NavigationMenu** (BelongsToStore):
- fillable: store_id, handle, title
- Relations: hasMany(NavigationItem, 'menu_id')->orderBy('position')

**NavigationItem**:
- fillable: menu_id, type, label, url, resource_id, position
- casts: type=>NavigationItemType
- $timestamps = false
- Relations: belongsTo(NavigationMenu, 'menu_id')
- Helper: `resolveUrl(): string` - returns URL based on type (link: url field; page/collection/product: lookup by resource_id and return route URL — for Phase 3, just return a placeholder string like `/pages/{handle}` for Page type, `/collections/{handle}` for Collection, `/products/{handle}` for Product)

Factories: realistic defaults for each.

### Step 5: Services

**`App\Services\NavigationService`**:
- `buildTree(NavigationMenu $menu): array` - returns flat list of items (no nesting for Phase 3). Each item has {id, type, label, url, position}.
- `resolveUrl(NavigationItem $item): string` - delegates to model method
- Cache: use Cache::remember("nav:menu:{$menuId}", 300, ...) per menu

**`App\Services\ThemeSettingsService`**:
- `forStore(Store $store): array` - returns settings for the store's active (published) theme. Cached 300s per store.
- Default settings if no published theme: ['colors' => ['primary' => '#111'], 'announcement' => null, 'footer_text' => '© Shop']
- Register as singleton in AppServiceProvider

### Step 6: Storefront Base Layout

Create `resources/views/components/layouts/storefront.blade.php`:
- Full HTML shell with `<head>` (meta, title, @vite('resources/css/app.css'), @livewireStyles, @fluxAppearance)
- Announcement bar slot (hidden if empty)
- Header: logo, main nav (reads from NavigationService), cart icon with badge, account link, search icon
- Main: {{ $slot }}
- Footer: footer nav + copyright from ThemeSettings
- Cart drawer slot (placeholder for Phase 4)
- @livewireScripts @fluxScripts
- Use Tailwind CSS v4 utility classes (dark mode via dark: prefix)
- NO FluxUI for storefront (Flux Free is admin-focused; use plain Tailwind)

Create `resources/views/storefront/partials/header.blade.php` and `footer.blade.php` as includes. Include them from the layout.

Create `resources/views/storefront/partials/announcement.blade.php`.

Design should be clean, modern, minimal. Neutral palette (zinc/neutral) with one accent color from theme settings. Dark mode support throughout.

### Step 7: Storefront Error Pages
- `resources/views/errors/404.blade.php` - uses storefront layout component, centered 404 message
- `resources/views/errors/503.blade.php` - uses storefront layout component, centered 503 maintenance message

### Step 8: Home Page Livewire Component (skeleton)

Create `app/Livewire/Storefront/Home.php`:
- Full-page component with #[Layout('components.layouts.storefront')]
- render(): returns view 'livewire.storefront.home'
- Loads featured collections and recent products (limit 8) - for Phase 3, just show a hero section + placeholder product grid (actual featured products will be hooked up in Phase 4 or later)
- NO products/collections dependency on data from tests — just render whatever is there

Create `resources/views/livewire/storefront/home.blade.php`:
- Hero section with headline, subheadline, CTA button (links to /collections or /search)
- Featured collections strip (3 columns desktop, 1 mobile)
- Recent products grid (4 columns desktop)
- Use `<x-storefront.product-card :product="$product" />` component if Product model is available

Create `resources/views/components/storefront/product-card.blade.php`:
- Clickable card with image (use placeholder), title, price from first variant, hover effect
- Props: $product

Create `resources/views/components/storefront/price.blade.php`:
- Formats price amount (cents) + currency
- Props: $amount, $currency

### Step 9: Routes
Add to `routes/web.php` (below admin routes):
```php
Route::get('/', \App\Livewire\Storefront\Home::class)->name('storefront.home');
```

For Phase 3, don't add store.resolve middleware to the storefront route yet — OR add it with a fallback that creates a default store if none exists. Simplest: add a `before` route group that looks up first Store and binds it to 'current_store' for dev convenience. Actually just add the route without middleware for now, and let the Home component work without tenant binding.

Wait — better approach: add a simple `store.resolve` middleware bypass for development. Use session('current_store_id') → first store if none. Or just: in the Home livewire component's mount(), if no 'current_store' is bound, bind the first Store from DB.

SIMPLEST: In Home::mount(), do:
```php
if (!app()->bound('current_store')) {
    $store = Store::first();
    if ($store) app()->instance('current_store', $store);
}
```
This allows the storefront to work in tests and dev without tenant resolution.

### Step 10: Pest Tests

Create these tests in appropriate dirs:

**tests/Feature/Themes/ThemeTest.php**:
- it creates a theme via factory
- it publishes a theme and sets published_at
- only one theme can be published per store (optional — if not enforced, just test that publishing works)
- it scopes themes to current store

**tests/Feature/Themes/ThemeSettingsTest.php**:
- ThemeSettingsService returns default settings for store without theme
- ThemeSettingsService returns theme settings_json for store with published theme

**tests/Feature/Pages/PageTest.php**:
- it creates a page via factory
- it scopes pages to current store
- page handle is unique per store

**tests/Feature/Navigation/NavigationTest.php**:
- NavigationService::buildTree returns ordered items
- NavigationItem::resolveUrl returns placeholder for Page/Collection/Product
- NavigationService caches tree

**tests/Feature/Storefront/HomeTest.php**:
- storefront home page renders via Livewire::test(Home::class)
- assertSeeHtml the hero section

Run tests and fix until green.

### Execution Order
1. Read schema spec sections
2. Create enums
3. Create migrations, verify `migrate:fresh`
4. Create models + factories
5. Create services (NavigationService, ThemeSettingsService)
6. Create storefront base layout + partials + error pages
7. Create Home Livewire component + views
8. Create product-card / price Blade components
9. Update routes/web.php
10. Write tests
11. Run `php artisan test --compact`
12. Run `vendor/bin/pint --dirty --format agent`
13. Report back

## Reporting
Message team-lead with summary + test results + decisions/blockers. Mark task #4 completed when Phase 3 tests are green.

Start now.


Agent

Phase 3 Themes/Pages/Navigation

Prompt:
You are a Laravel 12 backend engineer building Phase 3 (Themes, Pages, Navigation, Storefront Layout Scaffold) for the shop at /Users/fabianwesner/Herd/shop. Phases 1 and 2 are complete and committed. Work autonomously until Phase 3 tests pass.

## CRITICAL RULES
1. NEVER use the em-dash character (—)
2. Explicit PHP 8.4 return types + type hints
3. Use `php artisan make:*` for scaffolding
4. Run `vendor/bin/pint --dirty --format agent` before reporting done
5. Use `fake()` helper
6. Apply BelongsToStore trait to Theme, Page, NavigationMenu
7. DO NOT commit

## Phase 1+2 Artifacts
All enums, models from previous phases exist. Trait: `App\Models\Concerns\BelongsToStore`.

## Phase 3 Scope

### Step 1: Read Schema
Grep `specs/01-DATABASE-SCHEMA.md` for "themes" table to find column specs for themes, theme_files, theme_settings, pages, navigation_menus, navigation_items. Read the relevant sections.

### Step 2: Enums
Create in `app/Enums/`:
- `ThemeStatus`: Draft='draft', Published='published'
- `PageStatus`: Draft='draft', Published='published', Archived='archived'
- `NavigationItemType`: Link='link', Page='page', Collection='collection', Product='product'

### Step 3: Migrations (prefix 2026_04_12_1020NN)
1. `create_themes_table`: id, store_id FK, name, version, status (check), published_at nullable, timestamps
2. `create_theme_files_table`: id, theme_id FK, path, storage_key, sha256, byte_size. No timestamps (or only created_at per spec)
3. `create_theme_settings_table`: theme_id PK, settings_json, updated_at
4. `create_pages_table`: id, store_id FK, title, handle, body_html nullable, status (check), published_at nullable, timestamps. Unique (store_id, handle)
5. `create_navigation_menus_table`: id, store_id FK, handle, title, timestamps. Unique (store_id, handle)
6. `create_navigation_items_table`: id, menu_id FK, type (check), label, url nullable, resource_id nullable (BIGINT), position. No timestamps

Use triggers for CHECK constraints (Phase 1/2 pattern).

Run `php artisan migrate:fresh` after creating.

### Step 4: Models + Factories

**Theme** (BelongsToStore):
- fillable: store_id, name, version, status, published_at
- casts: status=>ThemeStatus, published_at=>datetime
- Relations: hasMany(ThemeFile), hasOne(ThemeSettings)

**ThemeFile**:
- fillable: theme_id, path, storage_key, sha256, byte_size
- $timestamps = false
- Relations: belongsTo(Theme)

**ThemeSettings**:
- $primaryKey = 'theme_id', $incrementing = false
- fillable: theme_id, settings_json
- casts: settings_json=>array
- const CREATED_AT = null
- Relations: belongsTo(Theme)

**Page** (BelongsToStore):
- fillable: store_id, title, handle, body_html, status, published_at
- casts: status=>PageStatus, published_at=>datetime

**NavigationMenu** (BelongsToStore):
- fillable: store_id, handle, title
- Relations: hasMany(NavigationItem, 'menu_id')->orderBy('position')

**NavigationItem**:
- fillable: menu_id, type, label, url, resource_id, position
- casts: type=>NavigationItemType
- $timestamps = false
- Relations: belongsTo(NavigationMenu, 'menu_id')
- Helper: `resolveUrl(): string` - returns URL based on type (link: url field; page/collection/product: lookup by resource_id and return route URL — for Phase 3, just return a placeholder string like `/pages/{handle}` for Page type, `/collections/{handle}` for Collection, `/products/{handle}` for Product)

Factories: realistic defaults for each.

### Step 5: Services

**`App\Services\NavigationService`**:
- `buildTree(NavigationMenu $menu): array` - returns flat list of items (no nesting for Phase 3). Each item has {id, type, label, url, position}.
- `resolveUrl(NavigationItem $item): string` - delegates to model method
- Cache: use Cache::remember("nav:menu:{$menuId}", 300, ...) per menu

**`App\Services\ThemeSettingsService`**:
- `forStore(Store $store): array` - returns settings for the store's active (published) theme. Cached 300s per store.
- Default settings if no published theme: ['colors' => ['primary' => '#111'], 'announcement' => null, 'footer_text' => '© Shop']
- Register as singleton in AppServiceProvider

### Step 6: Storefront Base Layout

Create `resources/views/components/layouts/storefront.blade.php`:
- Full HTML shell with `<head>` (meta, title, @vite('resources/css/app.css'), @livewireStyles, @fluxAppearance)
- Announcement bar slot (hidden if empty)
- Header: logo, main nav (reads from NavigationService), cart icon with badge, account link, search icon
- Main: {{ $slot }}
- Footer: footer nav + copyright from ThemeSettings
- Cart drawer slot (placeholder for Phase 4)
- @livewireScripts @fluxScripts
- Use Tailwind CSS v4 utility classes (dark mode via dark: prefix)
- NO FluxUI for storefront (Flux Free is admin-focused; use plain Tailwind)

Create `resources/views/storefront/partials/header.blade.php` and `footer.blade.php` as includes. Include them from the layout.

Create `resources/views/storefront/partials/announcement.blade.php`.

Design should be clean, modern, minimal. Neutral palette (zinc/neutral) with one accent color from theme settings. Dark mode support throughout.

### Step 7: Storefront Error Pages
- `resources/views/errors/404.blade.php` - uses storefront layout component, centered 404 message
- `resources/views/errors/503.blade.php` - uses storefront layout component, centered 503 maintenance message

### Step 8: Home Page Livewire Component (skeleton)

Create `app/Livewire/Storefront/Home.php`:
- Full-page component with #[Layout('components.layouts.storefront')]
- render(): returns view 'livewire.storefront.home'
- Loads featured collections and recent products (limit 8) - for Phase 3, just show a hero section + placeholder product grid (actual featured products will be hooked up in Phase 4 or later)
- NO products/collections dependency on data from tests — just render whatever is there

Create `resources/views/livewire/storefront/home.blade.php`:
- Hero section with headline, subheadline, CTA button (links to /collections or /search)
- Featured collections strip (3 columns desktop, 1 mobile)
- Recent products grid (4 columns desktop)
- Use `<x-storefront.product-card :product="$product" />` component if Product model is available

Create `resources/views/components/storefront/product-card.blade.php`:
- Clickable card with image (use placeholder), title, price from first variant, hover effect
- Props: $product

Create `resources/views/components/storefront/price.blade.php`:
- Formats price amount (cents) + currency
- Props: $amount, $currency

### Step 9: Routes
Add to `routes/web.php` (below admin routes):
```php
Route::get('/', \App\Livewire\Storefront\Home::class)->name('storefront.home');
```

For Phase 3, don't add store.resolve middleware to the storefront route yet — OR add it with a fallback that creates a default store if none exists. Simplest: add a `before` route group that looks up first Store and binds it to 'current_store' for dev convenience. Actually just add the route without middleware for now, and let the Home component work without tenant binding.

Wait — better approach: add a simple `store.resolve` middleware bypass for development. Use session('current_store_id') → first store if none. Or just: in the Home livewire component's mount(), if no 'current_store' is bound, bind the first Store from DB.

SIMPLEST: In Home::mount(), do:
```php
if (!app()->bound('current_store')) {
    $store = Store::first();
    if ($store) app()->instance('current_store', $store);
}
```
This allows the storefront to work in tests and dev without tenant resolution.

### Step 10: Pest Tests

Create these tests in appropriate dirs:

**tests/Feature/Themes/ThemeTest.php**:
- it creates a theme via factory
- it publishes a theme and sets published_at
- only one theme can be published per store (optional — if not enforced, just test that publishing works)
- it scopes themes to current store

**tests/Feature/Themes/ThemeSettingsTest.php**:
- ThemeSettingsService returns default settings for store without theme
- ThemeSettingsService returns theme settings_json for store with published theme

**tests/Feature/Pages/PageTest.php**:
- it creates a page via factory
- it scopes pages to current store
- page handle is unique per store

**tests/Feature/Navigation/NavigationTest.php**:
- NavigationService::buildTree returns ordered items
- NavigationItem::resolveUrl returns placeholder for Page/Collection/Product
- NavigationService caches tree

**tests/Feature/Storefront/HomeTest.php**:
- storefront home page renders via Livewire::test(Home::class)
- assertSeeHtml the hero section

Run tests and fix until green.

### Execution Order
1. Read schema spec sections
2. Create enums
3. Create migrations, verify `migrate:fresh`
4. Create models + factories
5. Create services (NavigationService, ThemeSettingsService)
6. Create storefront base layout + partials + error pages
7. Create Home Livewire component + views
8. Create product-card / price Blade components
9. Update routes/web.php
10. Write tests
11. Run `php artisan test --compact`
12. Run `vendor/bin/pint --dirty --format agent`
13. Report back

## Reporting
Message team-lead with summary + test results + decisions/blockers. Mark task #4 completed when Phase 3 tests are green.

Start now.


Phase 3 running. I'll parallelize Phase 4 backend since it's independent.


TaskCreate

Phase 4: Cart, Checkout, Discounts, Shipping, Taxes (backend)

Implement Phase 4 backend: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts migrations/models. Services: CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService state machine. Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts. Enums: CartStatus, CheckoutStatus, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode. Value objects: PricingResult, TaxLine. Exception: InvalidDiscountException. Pest tests: CartServiceTest, PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CheckoutStateTest.


Task #9 created successfully: Phase 4: Cart, Checkout, Discounts, Shipping, Taxes (backend)


Updated task #9 deleted


Waiting for Phase 3 to complete.


<teammate-message teammate_id="phase3-themes" color="yellow" summary="Phase 3 complete, 79/79 tests green">
Phase 3 (Themes, Pages, Navigation, Storefront Layout Scaffold) is complete. Task #4 marked completed.

Test results: 79 passed, 0 failed, 184 assertions (full suite). Pint clean. Migrations all run via `migrate:fresh`.

Phase 3 artifacts created:

Enums (app/Enums):
- ThemeStatus (Draft, Published)
- PageStatus (Draft, Published, Archived)
- NavigationItemType (Link, Page, Collection, Product)

Migrations (prefix 2026_04_12_1020NN):
- themes, theme_files, theme_settings, pages, navigation_menus, navigation_items
- CHECK constraints via BEFORE INSERT/UPDATE triggers for themes.status, pages.status, navigation_items.type
- Unique indexes: theme_files(theme_id,path), pages(store_id,handle), navigation_menus(store_id,handle)

Models (app/Models):
- Theme (BelongsToStore, casts status/published_at, hasMany files, hasOne settings)
- ThemeFile ($timestamps=false)
- ThemeSettings (primary key theme_id, non-incrementing, CREATED_AT=null, settings_json cast array)
- Page (BelongsToStore, casts status/published_at)
- NavigationMenu (BelongsToStore, items() ordered by position)
- NavigationItem ($timestamps=false, resolveUrl() returns /pages|/collections|/products/{resource_id} or the url for Link type)

Factories created for all six models, with published() / draft() / archived() / page() / collection() / product() states where relevant.

Services (app/Services):
- NavigationService::buildTree(menu) returns flat ordered array [{id,type,label,url,position}]; cached 300s under `nav:menu:{id}`.
- ThemeSettingsService::forStore(store) returns merged defaults + published theme settings_json; cached 300s under `theme:settings:store:{id}`; defaults are `colors.primary=#111`, `announcement=null`, `footer_text=(c) Shop`. Registered as singleton in AppServiceProvider::register().

Storefront views:
- resources/views/components/layouts/storefront.blade.php (full HTML shell, Vite CSS, @livewireStyles, @fluxAppearance, announcement/header/footer includes, cart drawer placeholder)
- resources/views/storefront/partials/{announcement,header,footer}.blade.php (uses NavigationService for main-menu/footer-menu, plain Tailwind, dark mode, inline SVG icons)
- resources/views/errors/{404,503}.blade.php (use the storefront layout component)

Livewire component:
- app/Livewire/Storefront/Home.php with #[Layout('components.layouts.storefront')]. mount() binds `current_store` to Store::first() if none bound. render() passes featuredCollections (limit 3) + recentProducts (limit 8) to the view.
- resources/views/livewire/storefront/home.blade.php: hero, featured collections strip (3-col desktop), recent products grid (2/3/4 col responsive). Uses <x-storefront.product-card />.
- resources/views/components/storefront/product-card.blade.php (reads price_amount + currency from first variant)
- resources/views/components/storefront/price.blade.php (formats cents -> decimal, prefixes currency symbol/code)

Routes (routes/web.php):
- GET / -> Storefront\Home named `home` (kept name to avoid breaking existing auth layouts/tests)
- GET /storefront -> Storefront\Home named `storefront.home` (for the spec-named route; partials use route('storefront.home'))

Tests (all green):
- tests/Feature/Themes/ThemeTest.php (4 tests: factory create, publish, scope to store, DB-level status check)
- tests/Feature/Themes/ThemeSettingsTest.php (3 tests: defaults, published theme overrides, caching)
- tests/Feature/Pages/PageTest.php (4 tests: factory create, scope, unique handle per store, same handle across stores)
- tests/Feature/Navigation/NavigationTest.php (3 tests: ordered tree, placeholder URL resolution, caching)
- tests/Feature/Storefront/HomeTest.php (2 tests: Livewire::test assertions, GET /)

Decisions / deviations from spec:
1. The spec said footer default `(c) Shop` — I used ASCII `(c)` instead of the copyright symbol to keep files plain ASCII and avoid any non-ASCII ambiguity. Same applied to currency symbols in price.blade.php (EUR/GBP/JPY use 3-letter code, USD keeps `$`).
2. Route naming: the existing auth layouts and tests/Feature/Auth/AuthenticationTest.php reference `route('home')`. To avoid breaking them, I kept `home` as the primary name for `/` and added a secondary `/storefront` route with name `storefront.home` (both point to the same Home class).
3. tests/Feature/ExampleTest.php previously hit `/` without RefreshDatabase (worked because `/` returned a static view). Since the storefront Home now queries the DB in mount(), I added `uses(RefreshDatabase::class)` to that existing test so it passes alongside the rest of the suite. No test was deleted.
4. Home Livewire component uses class_exists() guards before querying Collection/Product to stay resilient while dependent data is optional in Phase 3.
5. Theme uniqueness (one published per store) was not enforced at the DB level; the spec made it optional. Current behavior: publishing works, test asserts publish sets published_at.

No commits made (per instructions). Ready for review.
</teammate-message>

<teammate-message teammate_id="phase3-themes" color="yellow">
{"type":"idle_notification","from":"phase3-themes","timestamp":"2026-04-12T18:52:20.145Z","idleReason":"available"}
</teammate-message>


Bash

php artisan test --compact 2>&1 | tail -8 && echo "---" && git status --short

Verify Phase 3 tests and status


  ............................................................................
  ...

  Tests:    79 passed (184 assertions)
  Duration: 3.13s

---
 M .claude/settings.local.json
 M app/Providers/AppServiceProvider.php
 M routes/web.php
 M tests/Feature/ExampleTest.php
?? app/Enums/NavigationItemType.php
?? app/Enums/PageStatus.php
?? app/Enums/ThemeStatus.php
?? app/Livewire/Storefront/
?? app/Models/NavigationItem.php
?? app/Models/NavigationMenu.php
?? app/Models/Page.php
?? app/Models/Theme.php
?? app/Models/ThemeFile.php
?? app/Models/ThemeSettings.php
?? app/Services/NavigationService.php
?? app/Services/ThemeSettingsService.php
?? database/factories/NavigationItemFactory.php
?? database/factories/NavigationMenuFactory.php
?? database/factories/PageFactory.php
?? database/factories/ThemeFactory.php
?? database/factories/ThemeFileFactory.php
?? database/factories/ThemeSettingsFactory.php
?? database/migrations/2026_04_12_102001_create_themes_table.php
?? database/migrations/2026_04_12_102002_create_theme_files_table.php
?? database/migrations/2026_04_12_102003_create_theme_settings_table.php
?? database/migrations/2026_04_12_102004_create_pages_table.php
?? database/migrations/2026_04_12_102005_create_navigation_menus_table.php
?? database/migrations/2026_04_12_102006_create_navigation_items_table.php
?? resources/views/components/layouts/storefront.blade.php
?? resources/views/components/storefront/
?? resources/views/errors/
?? resources/views/livewire/storefront/
?? resources/views/storefront/
?? tests/Feature/Navigation/
?? tests/Feature/Pages/
?? tests/Feature/Storefront/
?? tests/Feature/Themes/


Undefined type 'App\Models\ThemeFile'.

Undefined type 'App\Models\ThemeSettings'.

Undefined type 'App\Models\NavigationItem'.

Symbol 'App\Providers\ThemeSettingsService' is declared but not used.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$service'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$store'.

Undefined type 'DB'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$store'.

Undefined property '$service'.


Edit

- [ ] Phase 3: Themes, pages, navigation, storefront layout

- [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing


# Shop Build Progress

Tracking progress for the full shop system implementation (team: shop-v2).

## Phase Checklist

- [x] Phase 1: Foundation (migrations, models, middleware, auth, authorization) - 42 tests passing
- [x] Phase 2: Catalog (products, variants, inventory, collections, media) - 63 tests passing
- [ ] Phase 3: Themes, pages, navigation, storefront layout
- [ ] Phase 4: Cart, checkout, discounts, shipping, taxes
- [ ] Phase 5: Payments, orders, fulfillment
- [ ] Phase 6: Customer accounts
- [ ] Phase 7: Admin panel
- [ ] Phase 8: Search
- [ ] Phase 9: Analytics
- [ ] Phase 10: Apps and webhooks
- [ ] Phase 11: Polish
- [ ] Phase 12: Full test suite execution + browser review

## Log

- Starting Phase 1: Foundation

-- [ ] Phase 3: Themes, pages, navigation, storefront layout

+- [x] Phase 3: Themes, pages, navigation, storefront layout - 79 tests passing


Bash

git add app/ database/ resources/ routes/ tests/ specs/ && git commit -m "$(cat <<'EOF'
Phase 3: Themes, pages, navigation, storefront layout

- Migrations: themes, theme_files, theme_settings, pages,
  navigation_menus, navigation_items
- Models + factories: Theme (BelongsToStore), ThemeFile, ThemeSettings,
  Page (BelongsToStore), NavigationMenu (BelongsToStore), NavigationItem
- Enums: ThemeStatus, PageStatus, NavigationItemType
- Services: NavigationService (cached menu trees), ThemeSettingsService
  (cached store settings, registered as singleton)
- Storefront base layout (layouts/storefront.blade.php) with
  announcement, header, footer, cart drawer slot
- Storefront partials (announcement, header, footer) wired to
  NavigationService
- Error pages (404, 503) using storefront layout
- Storefront\Home Livewire component with hero, featured collections,
  recent products grid
- Blade components: storefront.product-card, storefront.price
- Routes: GET / and GET /storefront pointing to Home
- Tests: ThemeTest, ThemeSettingsTest, PageTest, NavigationTest,
  Storefront\HomeTest (16 new tests, 79 total passing)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
EOF
)"

Commit Phase 3


[2026-04-12-claude-code-team 851d02f0] Phase 3: Themes, pages, navigation, storefront layout
 42 files changed, 1464 insertions(+), 5 deletions(-)
 create mode 100644 app/Enums/NavigationItemType.php
 create mode 100644 app/Enums/PageStatus.php
 create mode 100644 app/Enums/ThemeStatus.php
 create mode 100644 app/Livewire/Storefront/Home.php
 create mode 100644 app/Models/NavigationItem.php
 create mode 100644 app/Models/NavigationMenu.php
 create mode 100644 app/Models/Page.php
 create mode 100644 app/Models/Theme.php
 create mode 100644 app/Models/ThemeFile.php
 create mode 100644 app/Models/ThemeSettings.php
 create mode 100644 app/Services/NavigationService.php
 create mode 100644 app/Services/ThemeSettingsService.php
 create mode 100644 database/factories/NavigationItemFactory.php
 create mode 100644 database/factories/NavigationMenuFactory.php
 create mode 100644 database/factories/PageFactory.php
 create mode 100644 database/factories/ThemeFactory.php
 create mode 100644 database/factories/ThemeFileFactory.php
 create mode 100644 database/factories/ThemeSettingsFactory.php
 create mode 100644 database/migrations/2026_04_12_102001_create_themes_table.php
 create mode 100644 database/migrations/2026_04_12_102002_create_theme_files_table.php
 create mode 100644 database/migrations/2026_04_12_102003_create_theme_settings_table.php
 create mode 100644 database/migrations/2026_04_12_102004_create_pages_table.php
 create mode 100644 database/migrations/2026_04_12_102005_create_navigation_menus_table.php
 create mode 100644 database/migrations/2026_04_12_102006_create_navigation_items_table.php
 create mode 100644 resources/views/components/layouts/storefront.blade.php
 create mode 100644 resources/views/components/storefront/price.blade.php
 create mode 100644 resources/views/components/storefront/product-card.blade.php
 create mode 100644 resources/views/errors/404.blade.php
 create mode 100644 resources/views/errors/503.blade.php
 create mode 100644 resources/views/livewire/storefront/home.blade.php
 create mode 100644 resources/views/storefront/partials/announcement.blade.php
 create mode 100644 resources/views/storefront/partials/footer.blade.php
 create mode 100644 resources/views/storefront/partials/header.blade.php
 create mode 100644 tests/Feature/Navigation/NavigationTest.php
 create mode 100644 tests/Feature/Pages/PageTest.php
 create mode 100644 tests/Feature/Storefront/HomeTest.php
 create mode 100644 tests/Feature/Themes/ThemeSettingsTest.php
 create mode 100644 tests/Feature/Themes/ThemeTest.php


TaskCreate

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

Phase 4 implementation per specs/09 Phase 4 + specs/05 business logic. Migrations + models for carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Services: CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService. Value objects: PricingResult, TaxLine. Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts. Pest unit + feature tests.


Task #16 created successfully: Phase 4: Cart, Checkout, Discounts, Shipping, Taxes


Agent

Phase 4 Commerce implementer

Prompt:
You are a Laravel 12 backend engineer building Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes) for the shop at /Users/fabianwesner/Herd/shop. Phases 1, 2, and 3 are complete and committed. Work autonomously until tests pass.

## CRITICAL RULES
1. NEVER use em-dash character
2. PHP 8.4 explicit return types and type hints
3. All money in INTEGER cents
4. Tax rates in basis points (1900 = 19%)
5. `vendor/bin/pint --dirty --format agent` before reporting
6. Apply BelongsToStore trait to all store-scoped models
7. Use `fake()` helper
8. DO NOT commit
9. Use DB transactions for multi-table writes

## Already Available
Models from previous phases: Organization, Store, StoreDomain, StoreSettings, User, Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia, Theme, Page, Navigation*. Services: ProductService, InventoryService, etc. Trait `App\Models\Concerns\BelongsToStore`.

## Phase 4 Scope

### Step 1: Read Schema Spec
Grep `specs/01-DATABASE-SCHEMA.md` for these tables and read the relevant sections to get accurate column definitions: carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts. Also grep `specs/05-BUSINESS-LOGIC.md` for "PricingEngine", "CheckoutService", "DiscountService", "ShippingCalculator", "TaxCalculator" sections.

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

### Step 3: Migrations (prefix 2026_04_12_1030NN)
Create in dependency order. Use CHECK constraints via triggers (Phase 1 pattern):

1. `create_carts_table`: id, store_id FK, customer_id (BIGINT nullable, no FK since Customer table doesn't exist yet — add comment that FK will be added in Phase 6 or accept the orphan), session_id (string nullable), currency, cart_version (default 1), status (text+check default 'active'), created_at, updated_at
2. `create_cart_lines_table`: id, cart_id FK cascade, variant_id FK product_variants, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount (default 0), line_total_amount. No timestamps.
3. `create_checkouts_table`: id, store_id FK, cart_id FK, customer_id BIGINT nullable, status (text+check default 'started'), payment_method nullable, email nullable, shipping_address_json nullable text, billing_address_json nullable text, shipping_method_id nullable BIGINT, discount_code nullable, tax_provider_snapshot_json nullable text, totals_json nullable text, expires_at timestamp nullable, created_at, updated_at
4. `create_shipping_zones_table`: id, store_id FK, name, countries_json text (array), regions_json text nullable, created_at, updated_at
5. `create_shipping_rates_table`: id, zone_id FK, name, type (text+check), config_json text, is_active (bool default 1), created_at, updated_at
6. `create_tax_settings_table`: store_id PK, mode (text+check default 'manual'), provider nullable, prices_include_tax (bool default 0), config_json text, updated_at
7. `create_discounts_table`: id, store_id FK, type (text+check), code nullable, value_type (text+check), value_amount (int), starts_at timestamp nullable, ends_at timestamp nullable, usage_limit int nullable, usage_count int default 0, rules_json text nullable, status (text+check default 'draft'), created_at, updated_at. Composite unique (store_id, code) where code is not null.

Run `php artisan migrate:fresh` to verify.

### Step 4: Models + Factories

**Cart** (BelongsToStore):
- fillable: store_id, customer_id, session_id, currency, cart_version, status
- casts: status=>CartStatus
- Relations: belongsTo(Customer) (use string class ref since Customer doesn't exist), hasMany(CartLine), hasMany(Checkout)
- Helper: incrementVersion()

**CartLine**:
- fillable: cart_id, variant_id, quantity, unit_price_amount, line_subtotal_amount, line_discount_amount, line_total_amount
- $timestamps = false
- Relations: belongsTo(Cart), belongsTo(ProductVariant, 'variant_id')

**Checkout** (BelongsToStore):
- 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, tax_provider_snapshot_json=>array, totals_json=>array, expires_at=>datetime
- Relations: belongsTo(Cart)

**ShippingZone** (BelongsToStore):
- fillable: store_id, name, countries_json, regions_json
- casts: countries_json=>array, regions_json=>array
- Relations: hasMany(ShippingRate, 'zone_id')

**ShippingRate**:
- fillable: zone_id, name, type, config_json, is_active
- casts: type=>ShippingRateType, config_json=>array, is_active=>bool
- Relations: belongsTo(ShippingZone, 'zone_id')

**TaxSettings**:
- $primaryKey = 'store_id', $incrementing = false
- fillable: store_id, mode, provider, prices_include_tax, config_json
- casts: mode=>TaxMode, prices_include_tax=>bool, config_json=>array
- const CREATED_AT = null
- Relations: belongsTo(Store)

**Discount** (BelongsToStore):
- 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, starts_at=>datetime, ends_at=>datetime, rules_json=>array
- Relations: belongsTo(Store)
- Helper: isCurrentlyActive(): bool

Factories: realistic defaults. CartFactory, CartLineFactory, CheckoutFactory, ShippingZoneFactory (default countries=['DE','AT','CH']), ShippingRateFactory (flat 499 cents), TaxSettingsFactory, DiscountFactory (with states: percent10(), fixed500(), freeShipping(), expired(), disabled()).

### Step 5: Value Objects

`app/ValueObjects/PricingResult.php`:
```php
final readonly class PricingResult {
    public function __construct(
        public int $subtotal,
        public int $discount,
        public int $shipping,
        public array $taxLines, // TaxLine[]
        public int $taxTotal,
        public int $total,
        public string $currency,
        public bool $freeShippingApplied = false,
    ) {}

    public function toArray(): array { ... }
}
```

`app/ValueObjects/TaxLine.php`:
```php
final readonly class TaxLine {
    public function __construct(
        public string $name,
        public int $rate, // basis points
        public int $amount, // cents
    ) {}
}
```

`app/ValueObjects/DiscountResult.php`:
```php
final readonly class DiscountResult {
    public function __construct(
        public int $amount,
        public array $allocations, // [line_id => cents]
        public bool $freeShipping = false,
    ) {}
}
```

### Step 6: Exceptions
- `app/Exceptions/InvalidDiscountException.php` extends RuntimeException, with public string $reason and a static helper for each reason: `notFound()`, `expired()`, `notYetActive()`, `usageLimitReached()`, `minimumNotMet()`, `disabled()`

### Step 7: Services

**`App\Services\TaxCalculator`**:
```php
public function addExclusive(int $netAmount, int $rateBasisPoints): int {
    return (int) round($netAmount * $rateBasisPoints / 10000);
}
public function extractInclusive(int $grossAmount, int $rateBasisPoints): int {
    // tax = gross * rate / (10000 + rate)
    if ($rateBasisPoints === 0) return 0;
    return (int) round($grossAmount * $rateBasisPoints / (10000 + $rateBasisPoints));
}
public function calculate(int $amount, TaxSettings $settings, array $address): array {
    // returns ['tax_total' => int, 'tax_lines' => TaxLine[]]
    // For Phase 4: read $settings->config_json['rate_basis_points'] and $settings->config_json['name']
}
```

**`App\Services\ShippingCalculator`**:
```php
public function getAvailableRates(Store $store, array $address): Collection {
    // Find ShippingZones whose countries_json contains $address['country']
    // OR whose regions_json contains $address['country'].'-'.$address['province_code']
    // Return all is_active rates from matching zones
}
public function calculate(ShippingRate $rate, Cart $cart): int {
    // Based on $rate->type:
    //   flat: return config_json['amount']
    //   weight: traverse config_json['ranges'] [{min, max, amount}], cart total weight = sum of variant weight_g * qty
    //   price: same as weight but use cart subtotal
    //   carrier: stub - return config_json['fallback_amount'] ?? 0
}
```
If no cart line requires shipping (all variant.requires_shipping=false), calculator should return 0.

**`App\Services\DiscountService`**:
```php
public function validate(string $code, Store $store, Cart $cart): Discount {
    // Case-insensitive lookup using DB::raw('LOWER(code)')
    // Throw InvalidDiscountException with appropriate reason
}
public function calculate(Discount $discount, int $subtotal, Collection $lines): DiscountResult {
    // For percent: amount = floor(subtotal * value / 10000) - WAIT, value_amount is the percentage
    //   Actually convention: value_amount stores the "value" - for percent, store basis points (15% = 1500)
    //   The PricingEngine spec test says "percent 10% (value_amount = 10, value_type = percent)"
    //   So value_amount = 10 means 10%. Calculation: floor(subtotal * 10 / 100)
    // For fixed: amount = min(value_amount, subtotal)
    // For free_shipping: amount = 0, freeShipping = true
    // Allocate proportionally across lines, distribute remainder to last line
}
```

**`App\Services\PricingEngine`**:
```php
public function calculate(Checkout $checkout): PricingResult {
    $cart = $checkout->cart()->with('lines.variant')->first();
    $store = $checkout->store ?? Store::find($checkout->store_id);

    // 1. Subtotal
    $subtotal = $cart->lines->sum('line_subtotal_amount');

    // 2. Discount
    $discount = 0;
    $freeShipping = false;
    if ($checkout->discount_code) {
        $discountModel = $this->discountService->validate($checkout->discount_code, $store, $cart);
        $result = $this->discountService->calculate($discountModel, $subtotal, $cart->lines);
        $discount = $result->amount;
        $freeShipping = $result->freeShipping;
    }
    $discountedSubtotal = $subtotal - $discount;

    // 3. Shipping
    $shipping = 0;
    if ($checkout->shipping_method_id) {
        $rate = ShippingRate::find($checkout->shipping_method_id);
        if ($rate) {
            $shipping = $this->shippingCalculator->calculate($rate, $cart);
        }
    }
    if ($freeShipping) $shipping = 0;

    // 4. Tax
    $taxResult = ['tax_total' => 0, 'tax_lines' => []];
    $taxSettings = TaxSettings::firstWhere('store_id', $store->id);
    if ($taxSettings) {
        $taxBase = $discountedSubtotal + $shipping;
        $taxResult = $this->taxCalculator->calculate($taxBase, $taxSettings, $checkout->shipping_address_json ?? []);
    }

    // 5. Total
    $total = $discountedSubtotal + $shipping + $taxResult['tax_total'];

    // For prices_include_tax mode, the tax is already in the prices, so total = discountedSubtotal + shipping (extract tax from gross)
    if ($taxSettings && $taxSettings->prices_include_tax) {
        $extracted = $this->taxCalculator->extractInclusive($discountedSubtotal, $taxSettings->config_json['rate_basis_points'] ?? 0);
        $taxResult = ['tax_total' => $extracted, 'tax_lines' => [new TaxLine($taxSettings->config_json['name'] ?? 'Tax', $taxSettings->config_json['rate_basis_points'] ?? 0, $extracted)]];
        $total = $discountedSubtotal + $shipping;
    }

    return new PricingResult($subtotal, $discount, $shipping, $taxResult['tax_lines'], $taxResult['tax_total'], $total, $cart->currency, $freeShipping);
}
```

Constructor injects DiscountService, ShippingCalculator, TaxCalculator.

**`App\Services\CartService`**:
```php
public function create(Store $store, ?Customer $customer = null, ?string $sessionId = null): Cart
public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
public function removeLine(Cart $cart, int $lineId): void
public function getOrCreateForSession(Store $store, ?Customer $customer = null, ?string $sessionId = null): Cart
public function mergeOnLogin(Cart $guest, Cart $customer): Cart
```
- Validates variant + product is active before adding
- Checks inventory via InventoryService::checkAvailability
- If existing line for same variant: increment qty instead of new line
- All mutations call $cart->incrementVersion() then save
- Stores `unit_price_amount` from variant.price_amount at time of add (snapshot)
- Recalculates line_subtotal_amount = unit_price * quantity, line_total_amount = line_subtotal - line_discount

Customer parameter type: use `?\App\Models\Customer` as docblock and accept `null` for now since Customer model doesn't exist yet. Actually, define it as `?Authenticatable` or use mixed with comment. SIMPLEST: omit type entirely or use `?object` and document.

**`App\Services\CheckoutService`**:
```php
public function start(Cart $cart): Checkout
public function setAddress(Checkout $checkout, array $contactAndAddress): Checkout
public function setShippingMethod(Checkout $checkout, int $shippingRateId): Checkout
public function selectPaymentMethod(Checkout $checkout, string $method): Checkout
public function applyDiscount(Checkout $checkout, string $code): Checkout
public function recalculate(Checkout $checkout): Checkout // calls PricingEngine, stores totals_json
public function expire(Checkout $checkout): void
public function complete(Checkout $checkout, array $details): Checkout // stub for Phase 5 to override
```
Validates state machine transitions. Throws DomainException on invalid transitions.

### Step 8: Jobs
- `app/Jobs/ExpireAbandonedCheckouts.php` implements ShouldQueue. handle(): Checkout::where('expires_at', '<', now())->whereNotIn('status', ['completed','expired'])->each(fn($c) => $checkoutService->expire($c)). Releases reserved inventory via InventoryService for each cart line.
- `app/Jobs/CleanupAbandonedCarts.php` implements ShouldQueue. handle(): Cart::where('status','active')->where('updated_at', '<', now()->subDays(14))->update(['status' => 'abandoned']).

Schedule via `routes/console.php`:
```php
Schedule::job(new ExpireAbandonedCheckouts())->everyFifteenMinutes();
Schedule::job(new CleanupAbandonedCarts())->dailyAt('03:00');
```

### Step 9: Tests

**tests/Unit/PricingEngineTest.php** - Use Mockery for dependencies OR create real Cart/Checkout models with RefreshDatabase. Easier: use RefreshDatabase + factories. The spec calls these "unit" but they need DB access for Cart.
Tests:
- calculates subtotal from line items (2 lines: 2499x2 + 7999x1 = 12997)
- applies percent discount (10000 subtotal, 10% = 1000 discount, 9000 net)
- applies fixed discount (10000 subtotal, 500 fixed = 9500 net)
- caps fixed discount at subtotal (300 subtotal, 500 fixed = 300 discount, 0 net)
- applies free shipping (subtotal 5000, shipping 499, free_shipping discount, shipping = 0)
- calculates tax exclusive (10000 net, 1900 rate = 1900 tax, 11900 gross)
- extracts tax inclusive (11900 gross, 1900 rate = 1900 tax, 10000 net)
- zero tax when rate is 0
- calculates flat shipping (rate.type=flat, amount=499)
- calculates full checkout end-to-end

**tests/Unit/DiscountCalculatorTest.php**:
- validates active code
- rejects expired
- rejects not_yet_active
- rejects usage_limit_reached
- rejects unknown code
- case-insensitive lookup
- enforces minimum_purchase rule
- passes minimum_purchase when met
- calculates percent amount
- calculates fixed amount
- handles free_shipping type
- allocates proportionally across lines
- distributes rounding remainder to last line

**tests/Unit/TaxCalculatorTest.php**:
- exclusive tax math
- inclusive tax extraction
- zero rate returns 0
- zero amount returns 0
- non-standard rate (8999 net, 700bp = 629)
- small inclusive (119 gross, 1900bp = 19 tax)
- high rates (10000 net, 2500bp = 2500)

**tests/Unit/ShippingCalculatorTest.php**:
- matches zone by country
- matches zone by region (US-NY)
- empty when no zone matches
- flat rate
- weight-based rate (ranges)
- price-based rate (ranges)
- zero shipping when no items require shipping
- skips inactive rates
- multiple zones match returns all rates

**tests/Unit/CartVersionTest.php**:
- starts at version 1
- increments on add line
- increments on update quantity
- increments on remove line

**tests/Unit/HandleGeneratorTest.php**:
- generates a slug from a title
- appends suffix on collision
- excludes given id from collision check

**tests/Feature/Cart/CartServiceTest.php**:
- adds a line to a cart
- increments quantity for existing variant
- validates inventory before adding (throws InsufficientInventoryException with deny policy)
- removes a line
- updates quantity
- merges guest cart into customer cart on login

**tests/Feature/Checkout/CheckoutFlowTest.php**:
- starts checkout from cart
- transitions started -> addressed via setAddress
- transitions addressed -> shipping_selected
- transitions shipping_selected -> payment_selected
- rejects invalid transitions
- recalculates totals on each step

**tests/Feature/Checkout/CheckoutStateTest.php**:
- expires checkouts via job, releases reserved inventory

### Execution Order
1. Read schema spec sections
2. Create enums
3. Create migrations + run migrate:fresh
4. Create models + factories
5. Create value objects
6. Create exceptions
7. Create services in order: TaxCalculator, ShippingCalculator, DiscountService, PricingEngine, CartService, CheckoutService
8. Create jobs
9. Schedule jobs
10. Write tests
11. `php artisan test --compact` until green
12. `vendor/bin/pint --dirty --format agent`
13. `php artisan migrate:fresh`
14. Report back

## Reporting
Mark task #16 completed when tests pass. Send team-lead a summary message.

Start now.