Agent

Phase 2: Catalog build

Prompt:
You are building the CATALOG phase of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, SQLite, Pest v4, Livewire v4.

CONTEXT (already built — Phase 1 Foundation):
- All 46 tables are migrated (spec 01), including products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media.
- 28 string-backed enums exist in app/Enums (ProductStatus, VariantStatus, CollectionStatus, CollectionType, MediaType, MediaStatus, InventoryPolicy, ...). TitleCase keys, lowercase values.
- App\Models\Concerns\BelongsToStore trait + App\Models\Scopes\StoreScope exist (auto-scope by app('current_store'); no-op when unbound). Apply BelongsToStore to: Product, Collection, InventoryItem (per spec).
- Models exist: Organization, Store, StoreDomain, StoreSettings, StoreUser (pivot), User, Customer (minimal). tests/TestCase.php has createStore()/bindStore()/createUserWithRole() helpers. tests/Pest.php uses RefreshDatabase for Feature tests.
- Policies ProductPolicy + CollectionPolicy already exist in app/Policies.
- Conventions: PHP 8.4 constructor promotion, explicit return types, $fillable (never $guarded=[]), casts() method, factories for every model.

MANDATORY first steps:
1. Read specs/01-DATABASE-SCHEMA.md Epic 2 sections (products..product_media tables, lines ~153-369)
2. Read specs/05-BUSINESS-LOGIC.md sections 2 (Product Lifecycle), 3 (Inventory), 15 (Media Processing)
3. Read specs/09-IMPLEMENTATION-ROADMAP.md Phase 2 (lines ~245-348)
4. Read specs/06-AUTH-AND-SECURITY.md §4.5 (XSS / SanitizeHtml)
5. Activate skills: read .agents/skills/laravel-best-practices/SKILL.md and .agents/skills/pest-testing/SKILL.md and follow them.

BUILD:

## A. Models + factories (app/Models, database/factories)
Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia with relationships per spec 09 Phase 2 Step 2.2:
- Product: hasMany ProductVariant/ProductOption/ProductMedia, belongsToMany Collection via collection_products (with pivot position, orderBy position). BelongsToStore. Casts: tags=>array, published_at=>datetime, status=>ProductStatus. Add helpers: defaultVariant() (hasOne ProductVariant where is_default), active scope where status=active + published_at not null? (spec 05 §16.3 uses status active AND published_at NOT NULL for storefront visibility — add scopeVisible()).
- ProductOption: belongsTo Product, hasMany ProductOptionValue (orderBy position).
- ProductOptionValue: belongsTo ProductOption.
- ProductVariant: belongsTo Product, hasOne InventoryItem, belongsToMany ProductOptionValue via variant_option_values. Casts: requires_shipping=>bool, is_default=>bool, status=>VariantStatus. Helper: title() — e.g. "Blue / Medium" from option values; availableQuantity() via inventoryItem (on_hand - reserved); isInStock()/isBackorderable() honoring policy.
- InventoryItem: belongsTo ProductVariant, BelongsToStore. Casts policy=>InventoryPolicy. available() => on_hand - reserved.
- Collection: belongsToMany Product via collection_products with pivot position. BelongsToStore. Casts type=>CollectionType, status=>CollectionStatus.
- ProductMedia: belongsTo Product. Casts type=>MediaType, status=>MediaStatus. Helper url() returning Storage::disk('public')->url(storage_key) and urlFor(size) => media/{product_id}/{media_id}/{size}.{ext} variant path URL.
- Factories with meaningful states: ProductFactory (draft/active/archived states, withVariants(count) creating variants+inventory), ProductVariantFactory, InventoryItemFactory, CollectionFactory, ProductMediaFactory, ProductOptionFactory, ProductOptionValueFactory.

## B. Support + services
- App\Support\HandleGenerator::generate(string $title, string $table, int $storeId, ?int $excludeId = null): string — slugify (Str::slug), ensure unique per (store_id, handle) on the given table, append -1, -2... on collision (spec 05 §2.4).
- App\Actions\SanitizeHtml — invokable class sanitizing HTML per spec 06 §4.5 allowlist (p, br, strong, em, u, ol, ul, li, a[href], img[src,alt], h1-h6, blockquote, table/thead/tbody/tr/th/td, div, span; strip everything else and all other attributes; remove empty elements). Implement with DOMDocument (no new composer deps). Must strip script/style/event handlers/on* attributes and javascript: URLs.
- App\Services\ProductService per spec 05 §2.1 and spec 09 Phase 2 Step 2.3:
  - create(Store $store, array $data): Product — nested creation of product + options + option values + variants (+ variant_option_values pivots) + inventory items; auto-handle via HandleGenerator; sanitize description_html; when no options given create single default variant (is_default=1); validate SKU uniqueness per store (spec 05 §2.3, null/empty exempt).
  - update(Product $product, array $data): Product — same handling for updates.
  - transitionStatus(Product $product, ProductStatus $newStatus): void — enforce state machine per spec 05 §2.1 (Draft->Active requires >=1 variant with price>0 and non-empty title; Active/Archived->Draft blocked when order_lines reference the product's variants; Archived->Active same preconditions as Draft->Active; sets published_at on activation if null). Throws App\Exceptions\InvalidProductTransitionException. Dispatch App\Events\ProductStatusChanged.
  - delete(Product $product): void — hard delete only if draft AND zero order_lines references; otherwise throw InvalidProductTransitionException (or a dedicated exception).
- App\Services\VariantMatrixService::rebuildMatrix(Product $product): void per spec 05 §2.2 pseudocode (cartesian product of option values; create missing variants copying default pricing from first existing variant; archive orphaned variants that have order_line references, delete those without).
- App\Exceptions\InsufficientInventoryException, App\Exceptions\InvalidProductTransitionException.
- App\Services\InventoryService per spec 05 §3.3: checkAvailability(InventoryItem $item, int $quantity): bool; reserve/release/commit/restock(InventoryItem $item, int $quantity): void — all in DB transactions; reserve throws InsufficientInventoryException when policy=deny and available < quantity. commit decrements both on_hand and reserved. Use atomic updates (e.g. ->increment()/->decrement() or lockForUpdate) — SQLite single-writer is fine.
- App\Jobs\ProcessMediaUpload per spec 05 §15: accepts ProductMedia; resizes to thumbnail 150x150, small 300x300, medium 600x600, large 1200x1200 maintaining aspect ratio (contain, no crop); stores at media/{product_id}/{media_id}/{size}.{ext} on the public disk; updates record width/height/mime_type/byte_size from original and status ready; on failure status failed + log. Use plain PHP GD functions (check extension loaded; imagecreatefromjpeg/png/webp/gif, imagescale or manual resample to maintain aspect ratio). tries = 3.
- Media cleanup: on ProductMedia deleted, remove all files (original + sizes) — register in the model's booted() method (spec 05 §15.3).
- App\Events\ProductStatusChanged (and App\Events\ProductCreated/ProductUpdated/ProductDeleted if cheap — they are used by webhooks later; create them and dispatch from ProductService create/update/delete).

## C. Tests (Pest)
- tests/Unit/HandleGeneratorTest.php — slug format, uniqueness, suffix increment, per-store isolation, excludeId.
- tests/Unit/SanitizeHtmlTest.php — allowed tags survive, script/iframe stripped, on* attributes stripped, javascript: hrefs neutralized, img src/alt kept.
- tests/Feature/Products/ProductCrudTest.php — create product with options/variants/inventory via ProductService (assert nested graph, handle uniqueness per store), update, status transitions incl. blocked transitions (Active->Draft with order lines) and publish preconditions, delete rules (draft w/o orders deletable, others throw).
- tests/Feature/Products/VariantTest.php — matrix rebuild creates missing combos, preserves existing (price/sku/inventory intact), archives orphans with order refs, deletes orphans without; SKU uniqueness per store; default variant auto-creation.
- tests/Feature/Products/InventoryTest.php — reserve/release/commit/restock arithmetic, deny policy throws on oversell, continue policy allows, available = on_hand - reserved.
- tests/Feature/Products/CollectionTest.php — collection CRUD via a small CollectionService? NO — keep simple: test model relationships, product attach/detach with positions, BelongsToStore scoping on collections.
- tests/Feature/Products/MediaUploadTest.php — use Storage::fake('public'); create a real test image programmatically with GD in the test (e.g. imagecreatetruecolor + imagejpeg to a temp path); run ProcessMediaUpload synchronously; assert 4 sizes exist on disk with correct max dimensions and aspect ratio preserved; record updated to ready with width/height; failure path (garbage bytes) → status failed.
For order_lines references in tests: create rows directly with DB::table('order_lines')->insert(...) minimal columns needed (check migration for required fields) — orders/order_lines tables exist already; or create Order/OrderLine models? Order models are Phase 5 — use DB inserts to avoid building them now. Check the orders/order_lines migrations for NOT NULL columns without defaults.

## Verification (must all pass before reporting)
- php artisan test --compact (full suite, including Phase 1 tests)
- vendor/bin/pint --dirty --format agent
- Do NOT git commit. Do NOT modify specs/ or config/auth.php or bootstrap/app.php. If you hit a spec contradiction, decide reasonably and note it.

REPORT BACK: files created, test results (counts), any deviations/decisions.

Launching coder agent: Phase 2: Catalog build

Prompt:
You are building the CATALOG phase of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, SQLite, Pest v4, Livewire v4.

CONTEXT (already built — Phase 1 Foundation):
- All 46 tables are migrated (spec 01), including products, product_options, product_option_values, product_variants, variant_option_values, inventory_items, collections, collection_products, product_media.
- 28 string-backed enums exist in app/Enums (ProductStatus, VariantStatus, CollectionStatus, CollectionType, MediaType, MediaStatus, InventoryPolicy, ...). TitleCase keys, lowercase values.
- App\Models\Concerns\BelongsToStore trait + App\Models\Scopes\StoreScope exist (auto-scope by app('current_store'); no-op when unbound). Apply BelongsToStore to: Product, Collection, InventoryItem (per spec).
- Models exist: Organization, Store, StoreDomain, StoreSettings, StoreUser (pivot), User, Customer (minimal). tests/TestCase.php has createStore()/bindStore()/createUserWithRole() helpers. tests/Pest.php uses RefreshDatabase for Feature tests.
- Policies ProductPolicy + CollectionPolicy already exist in app/Policies.
- Conventions: PHP 8.4 constructor promotion, explicit return types, $fillable (never $guarded=[]), casts() method, factories for every model.

MANDATORY first steps:
1. Read specs/01-DATABASE-SCHEMA.md Epic 2 sections (products..product_media tables, lines ~153-369)
2. Read specs/05-BUSINESS-LOGIC.md sections 2 (Product Lifecycle), 3 (Inventory), 15 (Media Processing)
3. Read specs/09-IMPLEMENTATION-ROADMAP.md Phase 2 (lines ~245-348)
4. Read specs/06-AUTH-AND-SECURITY.md §4.5 (XSS / SanitizeHtml)
5. Activate skills: read .agents/skills/laravel-best-practices/SKILL.md and .agents/skills/pest-testing/SKILL.md and follow them.

BUILD:

## A. Models + factories (app/Models, database/factories)
Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia with relationships per spec 09 Phase 2 Step 2.2:
- Product: hasMany ProductVariant/ProductOption/ProductMedia, belongsToMany Collection via collection_products (with pivot position, orderBy position). BelongsToStore. Casts: tags=>array, published_at=>datetime, status=>ProductStatus. Add helpers: defaultVariant() (hasOne ProductVariant where is_default), active scope where status=active + published_at not null? (spec 05 §16.3 uses status active AND published_at NOT NULL for storefront visibility — add scopeVisible()).
- ProductOption: belongsTo Product, hasMany ProductOptionValue (orderBy position).
- ProductOptionValue: belongsTo ProductOption.
- ProductVariant: belongsTo Product, hasOne InventoryItem, belongsToMany ProductOptionValue via variant_option_values. Casts: requires_shipping=>bool, is_default=>bool, status=>VariantStatus. Helper: title() — e.g. "Blue / Medium" from option values; availableQuantity() via inventoryItem (on_hand - reserved); isInStock()/isBackorderable() honoring policy.
- InventoryItem: belongsTo ProductVariant, BelongsToStore. Casts policy=>InventoryPolicy. available() => on_hand - reserved.
- Collection: belongsToMany Product via collection_products with pivot position. BelongsToStore. Casts type=>CollectionType, status=>CollectionStatus.
- ProductMedia: belongsTo Product. Casts type=>MediaType, status=>MediaStatus. Helper url() returning Storage::disk('public')->url(storage_key) and urlFor(size) => media/{product_id}/{media_id}/{size}.{ext} variant path URL.
- Factories with meaningful states: ProductFactory (draft/active/archived states, withVariants(count) creating variants+inventory), ProductVariantFactory, InventoryItemFactory, CollectionFactory, ProductMediaFactory, ProductOptionFactory, ProductOptionValueFactory.

## B. Support + services
- App\Support\HandleGenerator::generate(string $title, string $table, int $storeId, ?int $excludeId = null): string — slugify (Str::slug), ensure unique per (store_id, handle) on the given table, append -1, -2... on collision (spec 05 §2.4).
- App\Actions\SanitizeHtml — invokable class sanitizing HTML per spec 06 §4.5 allowlist (p, br, strong, em, u, ol, ul, li, a[href], img[src,alt], h1-h6, blockquote, table/thead/tbody/tr/th/td, div, span; strip everything else and all other attributes; remove empty elements). Implement with DOMDocument (no new composer deps). Must strip script/style/event handlers/on* attributes and javascript: URLs.
- App\Services\ProductService per spec 05 §2.1 and spec 09 Phase 2 Step 2.3:
  - create(Store $store, array $data): Product — nested creation of product + options + option values + variants (+ variant_option_values pivots) + inventory items; auto-handle via HandleGenerator; sanitize description_html; when no options given create single default variant (is_default=1); validate SKU uniqueness per store (spec 05 §2.3, null/empty exempt).
  - update(Product $product, array $data): Product — same handling for updates.
  - transitionStatus(Product $product, ProductStatus $newStatus): void — enforce state machine per spec 05 §2.1 (Draft->Active requires >=1 variant with price>0 and non-empty title; Active/Archived->Draft blocked when order_lines reference the product's variants; Archived->Active same preconditions as Draft->Active; sets published_at on activation if null). Throws App\Exceptions\InvalidProductTransitionException. Dispatch App\Events\ProductStatusChanged.
  - delete(Product $product): void — hard delete only if draft AND zero order_lines references; otherwise throw InvalidProductTransitionException (or a dedicated exception).
- App\Services\VariantMatrixService::rebuildMatrix(Product $product): void per spec 05 §2.2 pseudocode (cartesian product of option values; create missing variants copying default pricing from first existing variant; archive orphaned variants that have order_line references, delete those without).
- App\Exceptions\InsufficientInventoryException, App\Exceptions\InvalidProductTransitionException.
- App\Services\InventoryService per spec 05 §3.3: checkAvailability(InventoryItem $item, int $quantity): bool; reserve/release/commit/restock(InventoryItem $item, int $quantity): void — all in DB transactions; reserve throws InsufficientInventoryException when policy=deny and available < quantity. commit decrements both on_hand and reserved. Use atomic updates (e.g. ->increment()/->decrement() or lockForUpdate) — SQLite single-writer is fine.
- App\Jobs\ProcessMediaUpload per spec 05 §15: accepts ProductMedia; resizes to thumbnail 150x150, small 300x300, medium 600x600, large 1200x1200 maintaining aspect ratio (contain, no crop); stores at media/{product_id}/{media_id}/{size}.{ext} on the public disk; updates record width/height/mime_type/byte_size from original and status ready; on failure status failed + log. Use plain PHP GD functions (check extension loaded; imagecreatefromjpeg/png/webp/gif, imagescale or manual resample to maintain aspect ratio). tries = 3.
- Media cleanup: on ProductMedia deleted, remove all files (original + sizes) — register in the model's booted() method (spec 05 §15.3).
- App\Events\ProductStatusChanged (and App\Events\ProductCreated/ProductUpdated/ProductDeleted if cheap — they are used by webhooks later; create them and dispatch from ProductService create/update/delete).

## C. Tests (Pest)
- tests/Unit/HandleGeneratorTest.php — slug format, uniqueness, suffix increment, per-store isolation, excludeId.
- tests/Unit/SanitizeHtmlTest.php — allowed tags survive, script/iframe stripped, on* attributes stripped, javascript: hrefs neutralized, img src/alt kept.
- tests/Feature/Products/ProductCrudTest.php — create product with options/variants/inventory via ProductService (assert nested graph, handle uniqueness per store), update, status transitions incl. blocked transitions (Active->Draft with order lines) and publish preconditions, delete rules (draft w/o orders deletable, others throw).
- tests/Feature/Products/VariantTest.php — matrix rebuild creates missing combos, preserves existing (price/sku/inventory intact), archives orphans with order refs, deletes orphans without; SKU uniqueness per store; default variant auto-creation.
- tests/Feature/Products/InventoryTest.php — reserve/release/commit/restock arithmetic, deny policy throws on oversell, continue policy allows, available = on_hand - reserved.
- tests/Feature/Products/CollectionTest.php — collection CRUD via a small CollectionService? NO — keep simple: test model relationships, product attach/detach with positions, BelongsToStore scoping on collections.
- tests/Feature/Products/MediaUploadTest.php — use Storage::fake('public'); create a real test image programmatically with GD in the test (e.g. imagecreatetruecolor + imagejpeg to a temp path); run ProcessMediaUpload synchronously; assert 4 sizes exist on disk with correct max dimensions and aspect ratio preserved; record updated to ready with width/height; failure path (garbage bytes) → status failed.
For order_lines references in tests: create rows directly with DB::table('order_lines')->insert(...) minimal columns needed (check migration for required fields) — orders/order_lines tables exist already; or create Order/OrderLine models? Order models are Phase 5 — use DB inserts to avoid building them now. Check the orders/order_lines migrations for NOT NULL columns without defaults.

## Verification (must all pass before reporting)
- php artisan test --compact (full suite, including Phase 1 tests)
- vendor/bin/pint --dirty --format agent
- Do NOT git commit. Do NOT modify specs/ or config/auth.php or bootstrap/app.php. If you hit a spec contradiction, decide reasonably and note it.

REPORT BACK: files created, test results (counts), any deviations/decisions.


Output:
agent_id: agent-1
actual_subagent_type: coder
status: completed

[summary]
Catalog phase complete. Full suite green: **78 passed (206 assertions)** — 15 pre-existing Phase 1 tests plus 63 new catalog tests (14 Unit + 49 Feature/Products). `vendor/bin/pint --dirty` applied minor fixes; suite re-run after pint, still green. No git commit; specs/, config/auth.php, bootstrap/app.php untouched.

## Files created

**Models** (`app/Models/`) — Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia
- `Product`: BelongsToStore, hasMany variants/options/media (all `orderBy position`), belongsToMany collections with pivot `position`, `defaultVariant()` hasOne, `scopeVisible()` (status=active AND published_at NOT NULL per spec 05 §16.3), casts tags→array, published_at→datetime, status→ProductStatus. A `deleting` hook deletes media records via Eloquent so file cleanup fires (DB cascade would bypass model events).
- `ProductVariant`: belongsTo product, hasOne inventoryItem, belongsToMany optionValues — **explicit pivot keys `variant_id`/`product_option_value_id`** (schema deviates from Laravel convention). Helpers `title()` ("Blue / Medium" ordered by option position), `availableQuantity()`, `isInStock()`, `isBackorderable()`.
- `ProductMedia`: `const UPDATED_AT = null` (table has only `created_at`); `booted()` `deleted` hook removes original + 4 size files; `url()`, `urlFor(size)`, `pathFor(size)` → `media/{product_id}/{media_id}/{size}.{ext}`; `SIZES` const.
- `ProductOption`/`ProductOptionValue`/`InventoryItem` use `$timestamps = false` (no timestamp columns in those tables).

**Factories** (`database/factories/`) — all 7, with states: Product `draft()/active()/archived()/withVariants(n)` (variants + inventory), Variant `default()/archived()/withInventory()`, InventoryItem `backorderable()`, Collection `automated()/draft()`, Media `ready()`.

**Support/Services/Jobs/Events/Exceptions**
- `app/Support/HandleGenerator.php` — static `generate()`, Str::slug + per-store collision suffix `-1, -2...`, `excludeId` support, `untitled` fallback for unslugifiable titles.
- `app/Actions/SanitizeHtml.php` — invokable, DOMDocument-based, no new deps. Allowlist per spec 06 §4.5; disallowed tags unwrapped except script/style (removed with content); all non-allowlisted attributes stripped; `href`/`src` limited to http/https/mailto/relative (javascript: neutralized); empty elements pruned iteratively.
- `app/Services/ProductService.php` — `create()` (nested options→values→matrix→variants→inventory in one transaction; auto-handle; sanitizes description; single default variant when no options), `update()` (options synced by id-then-name/value match with two-phase position writes so variant option-value links survive edits; matrix rebuilt), `transitionStatus()` (full state machine per spec 05 §2.1, sets published_at on activation, dispatches ProductStatusChanged), `delete()` (draft + zero order refs only). SKU uniqueness per store via join query, null/empty exempt, throws `ValidationException`. Dispatches ProductCreated/Updated/Deleted.
- `app/Services/VariantMatrixService.php` — `rebuildMatrix()` per spec pseudocode: cartesian combos, skip existing, create missing copying price/compare/currency/weight/shipping from first existing variant (first created becomes default), archive orphans with order_line refs, delete others.
- `app/Services/InventoryService.php` — checkAvailability/reserve/release/commit/restock, all in `DB::transaction` with `lockForUpdate` refresh; reserve throws `InsufficientInventoryException` on deny-policy oversell.
- `app/Jobs/ProcessMediaUpload.php` — ShouldQueue, `tries = 3`, plain GD: contain-fit (never upscale) to 150/300/600/1200, writes `media/{pid}/{mid}/{size}.{ext}` on public disk, sets width/height/mime/byte_size + status ready; `handle()` throws on failure (enables retries), `failed()` sets status failed + logs, original kept.
- `app/Exceptions/` — InsufficientInventoryException, InvalidProductTransitionException.
- `app/Events/` — ProductCreated, ProductUpdated, ProductDeleted, ProductStatusChanged.

**Tests** — `tests/Unit/HandleGeneratorTest.php` (5), `tests/Unit/SanitizeHtmlTest.php` (9), `tests/Feature/Products/ProductCrudTest.php` (17), `VariantTest.php` (11), `InventoryTest.php` (10), `CollectionTest.php` (6), `MediaUploadTest.php` (6). Media tests generate real JPEGs with GD against `Storage::fake('public')` and assert exact dimensions (incl. no-upscale large = original size). Order references use raw `DB::table` inserts via a `createOrderLineFor()` helper added to `tests/Pest.php` (orders/order_lines are Phase 5).

## Decisions / deviations (no spec contradictions)

- SKU uniqueness exception type is unspecified → used `Illuminate\Validation\ValidationException`.
- Same-status transitions throw `InvalidProductTransitionException` (spec table lists no no-ops).
- Publish precondition counts only **active** variants with price > 0.
- WebP derivative generation (spec 15.2 "if the server supports it") skipped — not in the build requirements; easily added later in `ProcessMediaUpload::saveImage`.
- Order-block checks look at both `order_lines.product_id` and `order_lines.variant_id` (both FK columns exist and either may reference the product).
- No `CollectionService` — per instructions, collection tests cover relationships, pivot positions, and BelongsToStore scoping directly.


Phase 2 done — 78 tests passing. Let me verify and commit, then launch Phase 3 (themes + storefront layout).


Bash

Running: php artisan test --compact 2>&1 | tail -4


Output:
  Tests:    78 passed (206 assertions)
  Duration: 1.13s


python3 - <<'EOF'
p = 'specs/progress.md'
s = open(p).read()
section = s.split('### Phase 2 — Catalog')[1].split('### Phase 3')[0]
s = s.replace(section, section.replace('- [ ]', '- [x]'))
s = s.replace('| 1 | 2026-07-19 | Phase 1 |', '| 1 | 2026-07-19 | Phase 1 |', 1)
s = s.replace('| pending |', '| 342ff904 |', 1)
s = s.replace('## Phase Checklist', '| 2 | 2026-07-19 | Phase 2 | Catalog: 7 models + factories, ProductService (state machine, SKU uniqueness), VariantMatrixService, InventoryService, HandleGenerator, SanitizeHtml, ProcessMediaUpload (GD). 78 tests green. | pending |\n\n## Phase Checklist', 1)
open(p, 'w').write(s)
print('ok')
EOF
git add -A && git commit -q -m "Phase 2: Catalog — product/variant/inventory/collection/media models, services, jobs

- ProductService with full status state machine + SKU uniqueness per store
- VariantMatrixService cartesian rebuild (preserve/archive/delete)
- InventoryService reserve/release/commit/restock in transactions
- HandleGenerator, SanitizeHtml (DOMDocument allowlist)
- ProcessMediaUpload job (GD, 4 sizes, aspect-ratio contain)
- 63 new tests: 78 passed total" && git log --oneline -1

Running: python3 - <<'EOF'
p = 'specs/progress.md'
s = open…


Output:
ok
3294c6ff Phase 2: Catalog — product/variant/inventory/collection/media models, services, jobs


<system-reminder>
The TodoList tool has not been updated recently. If you are working on tasks that benefit from progress tracking, consider using TodoList to update task status. Also consider clearing or rewriting the todo list if it has become stale and no longer matches the current work. Only use it if relevant. This is a gentle reminder; ignore it if not applicable. Make sure that you NEVER mention this reminder to the user.

Current todo list:
1. [done] Setup: deps (sanctum, pest-browser), .env.testing, Herd links, remove starter-kit scaffolding, specs/progress.md
2. [in_progress] Phase 1: Foundation — all migrations, core models, enums, middleware, auth guards, trait/scope, policies
3. [pending] Phase 2: Catalog — products/variants/inventory/collections/media + services + tests
4. [pending] Phase 3: Themes/pages/navigation + storefront layout + tests
5. [pending] Phase 4: Cart/checkout/discounts/shipping/taxes + services + API + tests
6. [pending] Phase 5: Payments/orders/fulfillment/customers + services + events + tests
7. [pending] Phase 6: Customer accounts + admin auth UI + tests
8. [pending] Phase 7: Admin panel (dashboard, products, orders, all sections) + tests
9. [pending] Phase 8: Search (FTS5) + observer + UI + tests
10. [pending] Phase 9: Analytics (events, aggregation, dashboard) + tests
11. [pending] Phase 10: Apps/webhooks + developers UI + admin REST API + tests
12. [pending] Phase 11: Seeders with exact demo data per spec 07
13. [pending] Phase 12: Polish (error pages, a11y, dark mode, pint) + full suite green
14. [pending] Pest browser tests per spec 08
15. [pending] Playwright MCP E2E verification of all acceptance criteria + bug fixes
16. [pending] Final review meeting: showcase all customer + admin features
</system-reminder>


Update todos: Phase 1 and 2 done, Phase 3 in progress. Then launch Phase 3 subagent (themes, pages, navigation, storefront layout + storefront browse components).


TodoList

Setup: deps (sanctum, pest-browser), .env.testing, Herd links, remove starter-kit scaffolding, specs/progress.md

Phase 1: Foundation — all migrations, core models, enums, middleware, auth guards, trait/scope, policies

Phase 2: Catalog — products/variants/inventory/collections/media + services + tests

Phase 3: Themes/pages/navigation + storefront layout + tests

Phase 4: Cart/checkout/discounts/shipping/taxes + services + API + tests

Phase 5: Payments/orders/fulfillment/customers + services + events + tests

Phase 6: Customer accounts + admin auth UI + tests

Phase 7: Admin panel (dashboard, products, orders, all sections) + tests

Phase 8: Search (FTS5) + observer + UI + tests

Phase 9: Analytics (events, aggregation, dashboard) + tests

Phase 10: Apps/webhooks + developers UI + admin REST API + tests

Phase 11: Seeders with exact demo data per spec 07

Phase 12: Polish (error pages, a11y, dark mode, pint) + full suite green

Pest browser tests per spec 08

Playwright MCP E2E verification of all acceptance criteria + bug fixes

Final review meeting: showcase all customer + admin features

Updating todo list

Pest browser tests per spec 08


Pest browser tests per spec 08


Output:
Todo list updated.
Current todo list:
  [done] Setup: deps (sanctum, pest-browser), .env.testing, Herd links, remove starter-kit scaffolding, specs/progress.md
  [done] Phase 1: Foundation — all migrations, core models, enums, middleware, auth guards, trait/scope, policies
  [done] Phase 2: Catalog — products/variants/inventory/collections/media + services + tests
  [in_progress] Phase 3: Themes/pages/navigation + storefront layout + tests
  [pending] Phase 4: Cart/checkout/discounts/shipping/taxes + services + API + tests
  [pending] Phase 5: Payments/orders/fulfillment/customers + services + events + tests
  [pending] Phase 6: Customer accounts + admin auth UI + tests
  [pending] Phase 7: Admin panel (dashboard, products, orders, all sections) + tests
  [pending] Phase 8: Search (FTS5) + observer + UI + tests
  [pending] Phase 9: Analytics (events, aggregation, dashboard) + tests
  [pending] Phase 10: Apps/webhooks + developers UI + admin REST API + tests
  [pending] Phase 11: Seeders with exact demo data per spec 07
  [pending] Phase 12: Polish (error pages, a11y, dark mode, pint) + full suite green
  [pending] Pest browser tests per spec 08
  [pending] Playwright MCP E2E verification of all acceptance criteria + bug fixes
  [pending] Final review meeting: showcase all customer + admin features

Ensure that you continue to use the todo list to track progress. Mark tasks done immediately after finishing them, and keep exactly one task in_progress when work is underway.


Phase 3: Themes, Pages, Navigation, Storefront layout + storefront browse Livewire components (Home, Collections, Products, Pages). This is a UI-heavy phase. The subagent needs to read specs/04-STOREFRONT-UI.md carefully (1922 lines) — the whole file is relevant for layout + home + collection + product + pages sections (§1-5, §12-17, §19). Cart drawer is in the layout but its functionality is Phase 4 — the layout can include a placeholder cart drawer shell that Phase 4 wires up. Actually, spec 04 §6 covers cart drawer. Let me have Phase 3 build layout with the drawer shell and Phase 4 implement cart logic. Or better: Phase 4 builds CartDrawer component fully. I'll instruct Phase 3 to include a stub.

Important Livewire v4 knowledge: full-page components, layout via `->layout('storefront.layouts.app')` or the `#[Layout]` attribute. The subagent should read the livewire-development + fluxui-development + tailwindcss-development skills.

Also Vite/Tailwind: resources/css/app.css exists (Tailwind v4). Need `npm run build` at the end so public/build has assets. Flux UI requires @flux scripts — in Livewire v4 + Flux v2, include `@fluxAppearance`? The subagent will figure it from the skill/docs.

Storefront theme system (spec 04 §1): themes have files + settings; the active theme's settings drive sections. Keep pragmatic: ThemeSettings service loads published theme settings_json; home page renders dynamic sections from settings.

Let me write the prompt.


Launching Phase 3: themes, pages, navigation, and the storefront layout + browse experience.


Agent

Phase 3: Themes + storefront

Prompt:
You are building PHASE 3 (Themes, Pages, Navigation, Storefront layout + browse experience) of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, Livewire v4, Flux UI Free v2, Tailwind CSS v4, SQLite, Pest v4.

CONTEXT (already built):
- Phase 1: all 46 tables migrated; 28 enums in app/Enums (ThemeStatus, PageStatus, NavigationItemType exist); BelongsToStore trait + StoreScope; ResolveStore middleware (alias store.resolve with param storefront|admin; binds app('current_store') and shares `currentStore` with views); routes/web.php has a placeholder storefront group with middleware store.resolve:storefront — REPLACE it with real routes; tests/TestCase.php has createStore/bindStore helpers; RefreshDatabase enabled for Feature tests.
- Phase 2: Product/ProductVariant/ProductOption/ProductOptionValue/InventoryItem/Collection/ProductMedia models + factories (ProductFactory has ->active() and ->withVariants(n) states; Product has scopeVisible()); ProductService; HandleGenerator; SanitizeHtml.
- routes/admin.php and routes/api.php exist with placeholders — DO NOT touch them.

MANDATORY first steps:
1. Read specs/04-STOREFRONT-UI.md ENTIRELY (1922 lines — theme system, base layout, home/collection/product/cart-drawer/page sections, components, currency formatting, accessibility, SEO)
2. Read specs/05-BUSINESS-LOGIC.md section 1 (already known) and spec 09 Phase 3 (lines ~349-441)
3. Read specs/01-DATABASE-SCHEMA.md Epic 3 (themes, theme_files, theme_settings, pages, navigation_menus, navigation_items)
4. Activate skills — READ THESE FILES and follow them:
   - .agents/skills/livewire-development/SKILL.md
   - .agents/skills/fluxui-development/SKILL.md
   - .agents/skills/tailwindcss-development/SKILL.md
   - .agents/skills/pest-testing/SKILL.md
   - .agents/skills/laravel-best-practices/SKILL.md

BUILD:

## A. Models + factories
Theme (BelongsToStore; hasMany ThemeFile; hasOne ThemeSettings; casts status=>ThemeStatus, published_at=>datetime; helper publish()/duplicate() can be simple), ThemeFile (belongsTo Theme), ThemeSettings (belongsTo Theme; casts settings_json=>array), Page (BelongsToStore; casts status=>PageStatus, published_at=>datetime; auto-handle via HandleGenerator on creating when handle empty; sanitize body_html via SanitizeHtml on saving), NavigationMenu (BelongsToStore; hasMany NavigationItem orderBy position), NavigationItem (belongsTo NavigationMenu; casts type=>NavigationItemType). Factories for all six.

## B. Services
- App\Services\NavigationService per spec 09 Phase 3 Step 3.5: buildTree(NavigationMenu $menu): array; resolveUrl(NavigationItem $item): string (link=>url, page=>/pages/{handle}, collection=>/collections/{handle}, product=>/products/{handle}); cache per store, 5-min TTL (cache key nav:{store_id}:{menu_handle}; invalidate on menu/item save — register invalidation in model booted()).
- App\Services\ThemeSettingsService: singleton registered in AppServiceProvider; loads + caches (5 min) the published theme's settings_json for current store; exposes get(key, default) and all(); sensible DEFAULT settings array (colors, announcement bar text, sections order) when no published theme exists.

## C. Storefront layout (resources/views/storefront/layouts/app.blade.php)
Per spec 04 §2: skip link, announcement bar (from theme settings), header (logo/store name, main navigation from NavigationService 'main-menu' with resolveUrl, search icon button, account icon link to /account, cart button with item count badge), main content, footer (footer menu + store info + payment icons placeholder), cart drawer placeholder container. Dark mode support via dark: classes. Tailwind v4 via @vite(['resources/css/app.css','resources/js/app.js']). Currency formatting helper per spec 04 "Currency Formatting" — create App\Support\Money::format(int $cents, string $currency): string (EUR => "24,99 €" or "€24.99"? — follow spec 04 §Currency Formatting exactly).
Livewire full-page component layout integration per Livewire v4 conventions (check the livewire skill for how v4 full-page components declare layouts — likely #[Layout('storefront.layouts.app')] attribute or ->layout() in render).
Include a <livewire:storefront.cart-drawer /> placeholder? Cart drawer is Phase 4 — include an Alpine-based drawer shell in the layout now (open/close via browser event 'cart-drawer-open'), empty body stating "Cart is empty" — Phase 4 will replace the inner content with the real component. Keep it clean.

## D. Blade components (resources/views/storefront/components/)
Per spec 04 §16: product-card.blade.php, price.blade.php, badge.blade.php, quantity-selector.blade.php, breadcrumbs.blade.php, pagination.blade.php. Address-form + order-summary are Phase 4 — skip those.

## E. Storefront routes + Livewire full-page components
Routes in routes/web.php (storefront group, store.resolve:storefront middleware):
- GET / → App\Livewire\Storefront\Home (name: storefront.home)
- GET /collections → Storefront\Collections\Index (storefront.collections.index)
- GET /collections/{handle} → Storefront\Collections\Show (storefront.collections.show)
- GET /products/{handle} → Storefront\Products\Show (storefront.products.show)
- GET /pages/{handle} → Storefront\Pages\Show (storefront.pages.show)
Behaviors per spec 04:
- Home (§3): hero section, featured collection(s) from theme settings, product grid of visible products, sections driven by theme settings with graceful fallbacks.
- Collections Index: grid of active collections.
- Collections Show (§4): collection title/description, product grid (visible products only), sort dropdown (featured/price asc/price desc/newest), pagination (12/page? follow spec). Filters by vendor/product_type if spec says (keep to spec).
- Products Show (§5): image gallery (ProductMedia ready, thumbnail strip + main image), title, vendor, price with compare-at strike-through + sale badge, variant option selectors (buttons/dropdowns for each option; resolve selected variant from chosen option values; show unavailable state), stock state (in stock / out of stock per policy deny → "Sold out" / continue → "Available on backorder"), quantity selector, add-to-cart button (wire it to dispatch a browser event 'add-to-cart' with variant id + qty for now — Phase 4 wires the real cart; ALSO track product_view analytics? that's Phase 9 — skip), description_html (sanitized, rendered with {!! !!}), JSON-LD structured data per spec §19, 404 for non-visible products.
- Pages Show (§12): render published page title + body_html, 404 otherwise.
- SEO per spec §19: title/meta description per page, canonical, OG tags.
- Accessibility per spec §15: skip link, aria labels, alt texts, focus styles.

## F. Wire up storefront in the dev environment so it renders
- Ensure npm run build succeeds (Tailwind v4 + Vite) — run it.
- Create a quick manual smoke check: seed a throwaway store with a couple of products via tinker or a test, and assert HTTP 200 via a Feature test instead (tests below). Do NOT write permanent seeders (Phase 11 owns seeders).

## G. Tests (Pest, Feature)
tests/Feature/Storefront/BrowsingTest.php:
- home page 200 for a store with published theme + visible products, contains product title; 404 unknown hostname (already covered in Phase 1, skip); unknown product handle → 404; draft product → 404.
- collections index lists active collections only; collection show lists only visible products of that collection (product in collection via pivot), pagination works (create 15 products, assert 12 on page 1 + page 2 link).
- product page shows title, price formatted per Money helper, variant options render, out-of-stock variant (inventory 0, policy deny) shows Sold out and disabled button; backorder (policy continue, 0 stock) shows "Available on backorder" and enabled button.
- pages show renders published page, draft page 404.
- navigation: create main-menu with page/collection/product/link items; assert NavigationService::buildTree + resolveUrl; assert header renders item labels.
- Money unit test (tests/Unit/MoneyTest.php) for formatting per spec.
- ThemeSettingsService test: defaults when no theme; published theme settings win.

## Rules
- Livewire v4 conventions from the skill (full-page components, layouts). Keep components in app/Livewire/Storefront/... with matching views.
- All visible strings English. All prices via the Money helper.
- Dark mode classes on all views. Responsive (mobile-first).
- php artisan test --compact must pass fully (78 existing + new). vendor/bin/pint --dirty --format agent at the end. npm run build must succeed.
- Do NOT git commit. Do NOT modify specs/. Do not create documentation files.

REPORT BACK: files created, test results, npm build result, deviations/decisions.

Launching coder agent: Phase 3: Themes + storefront

Prompt:
You are building PHASE 3 (Themes, Pages, Navigation, Storefront layout + browse experience) of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, Livewire v4, Flux UI Free v2, Tailwind CSS v4, SQLite, Pest v4.

CONTEXT (already built):
- Phase 1: all 46 tables migrated; 28 enums in app/Enums (ThemeStatus, PageStatus, NavigationItemType exist); BelongsToStore trait + StoreScope; ResolveStore middleware (alias store.resolve with param storefront|admin; binds app('current_store') and shares `currentStore` with views); routes/web.php has a placeholder storefront group with middleware store.resolve:storefront — REPLACE it with real routes; tests/TestCase.php has createStore/bindStore helpers; RefreshDatabase enabled for Feature tests.
- Phase 2: Product/ProductVariant/ProductOption/ProductOptionValue/InventoryItem/Collection/ProductMedia models + factories (ProductFactory has ->active() and ->withVariants(n) states; Product has scopeVisible()); ProductService; HandleGenerator; SanitizeHtml.
- routes/admin.php and routes/api.php exist with placeholders — DO NOT touch them.

MANDATORY first steps:
1. Read specs/04-STOREFRONT-UI.md ENTIRELY (1922 lines — theme system, base layout, home/collection/product/cart-drawer/page sections, components, currency formatting, accessibility, SEO)
2. Read specs/05-BUSINESS-LOGIC.md section 1 (already known) and spec 09 Phase 3 (lines ~349-441)
3. Read specs/01-DATABASE-SCHEMA.md Epic 3 (themes, theme_files, theme_settings, pages, navigation_menus, navigation_items)
4. Activate skills — READ THESE FILES and follow them:
   - .agents/skills/livewire-development/SKILL.md
   - .agents/skills/fluxui-development/SKILL.md
   - .agents/skills/tailwindcss-development/SKILL.md
   - .agents/skills/pest-testing/SKILL.md
   - .agents/skills/laravel-best-practices/SKILL.md

BUILD:

## A. Models + factories
Theme (BelongsToStore; hasMany ThemeFile; hasOne ThemeSettings; casts status=>ThemeStatus, published_at=>datetime; helper publish()/duplicate() can be simple), ThemeFile (belongsTo Theme), ThemeSettings (belongsTo Theme; casts settings_json=>array), Page (BelongsToStore; casts status=>PageStatus, published_at=>datetime; auto-handle via HandleGenerator on creating when handle empty; sanitize body_html via SanitizeHtml on saving), NavigationMenu (BelongsToStore; hasMany NavigationItem orderBy position), NavigationItem (belongsTo NavigationMenu; casts type=>NavigationItemType). Factories for all six.

## B. Services
- App\Services\NavigationService per spec 09 Phase 3 Step 3.5: buildTree(NavigationMenu $menu): array; resolveUrl(NavigationItem $item): string (link=>url, page=>/pages/{handle}, collection=>/collections/{handle}, product=>/products/{handle}); cache per store, 5-min TTL (cache key nav:{store_id}:{menu_handle}; invalidate on menu/item save — register invalidation in model booted()).
- App\Services\ThemeSettingsService: singleton registered in AppServiceProvider; loads + caches (5 min) the published theme's settings_json for current store; exposes get(key, default) and all(); sensible DEFAULT settings array (colors, announcement bar text, sections order) when no published theme exists.

## C. Storefront layout (resources/views/storefront/layouts/app.blade.php)
Per spec 04 §2: skip link, announcement bar (from theme settings), header (logo/store name, main navigation from NavigationService 'main-menu' with resolveUrl, search icon button, account icon link to /account, cart button with item count badge), main content, footer (footer menu + store info + payment icons placeholder), cart drawer placeholder container. Dark mode support via dark: classes. Tailwind v4 via @vite(['resources/css/app.css','resources/js/app.js']). Currency formatting helper per spec 04 "Currency Formatting" — create App\Support\Money::format(int $cents, string $currency): string (EUR => "24,99 €" or "€24.99"? — follow spec 04 §Currency Formatting exactly).
Livewire full-page component layout integration per Livewire v4 conventions (check the livewire skill for how v4 full-page components declare layouts — likely #[Layout('storefront.layouts.app')] attribute or ->layout() in render).
Include a <livewire:storefront.cart-drawer /> placeholder? Cart drawer is Phase 4 — include an Alpine-based drawer shell in the layout now (open/close via browser event 'cart-drawer-open'), empty body stating "Cart is empty" — Phase 4 will replace the inner content with the real component. Keep it clean.

## D. Blade components (resources/views/storefront/components/)
Per spec 04 §16: product-card.blade.php, price.blade.php, badge.blade.php, quantity-selector.blade.php, breadcrumbs.blade.php, pagination.blade.php. Address-form + order-summary are Phase 4 — skip those.

## E. Storefront routes + Livewire full-page components
Routes in routes/web.php (storefront group, store.resolve:storefront middleware):
- GET / → App\Livewire\Storefront\Home (name: storefront.home)
- GET /collections → Storefront\Collections\Index (storefront.collections.index)
- GET /collections/{handle} → Storefront\Collections\Show (storefront.collections.show)
- GET /products/{handle} → Storefront\Products\Show (storefront.products.show)
- GET /pages/{handle} → Storefront\Pages\Show (storefront.pages.show)
Behaviors per spec 04:
- Home (§3): hero section, featured collection(s) from theme settings, product grid of visible products, sections driven by theme settings with graceful fallbacks.
- Collections Index: grid of active collections.
- Collections Show (§4): collection title/description, product grid (visible products only), sort dropdown (featured/price asc/price desc/newest), pagination (12/page? follow spec). Filters by vendor/product_type if spec says (keep to spec).
- Products Show (§5): image gallery (ProductMedia ready, thumbnail strip + main image), title, vendor, price with compare-at strike-through + sale badge, variant option selectors (buttons/dropdowns for each option; resolve selected variant from chosen option values; show unavailable state), stock state (in stock / out of stock per policy deny → "Sold out" / continue → "Available on backorder"), quantity selector, add-to-cart button (wire it to dispatch a browser event 'add-to-cart' with variant id + qty for now — Phase 4 wires the real cart; ALSO track product_view analytics? that's Phase 9 — skip), description_html (sanitized, rendered with {!! !!}), JSON-LD structured data per spec §19, 404 for non-visible products.
- Pages Show (§12): render published page title + body_html, 404 otherwise.
- SEO per spec §19: title/meta description per page, canonical, OG tags.
- Accessibility per spec §15: skip link, aria labels, alt texts, focus styles.

## F. Wire up storefront in the dev environment so it renders
- Ensure npm run build succeeds (Tailwind v4 + Vite) — run it.
- Create a quick manual smoke check: seed a throwaway store with a couple of products via tinker or a test, and assert HTTP 200 via a Feature test instead (tests below). Do NOT write permanent seeders (Phase 11 owns seeders).

## G. Tests (Pest, Feature)
tests/Feature/Storefront/BrowsingTest.php:
- home page 200 for a store with published theme + visible products, contains product title; 404 unknown hostname (already covered in Phase 1, skip); unknown product handle → 404; draft product → 404.
- collections index lists active collections only; collection show lists only visible products of that collection (product in collection via pivot), pagination works (create 15 products, assert 12 on page 1 + page 2 link).
- product page shows title, price formatted per Money helper, variant options render, out-of-stock variant (inventory 0, policy deny) shows Sold out and disabled button; backorder (policy continue, 0 stock) shows "Available on backorder" and enabled button.
- pages show renders published page, draft page 404.
- navigation: create main-menu with page/collection/product/link items; assert NavigationService::buildTree + resolveUrl; assert header renders item labels.
- Money unit test (tests/Unit/MoneyTest.php) for formatting per spec.
- ThemeSettingsService test: defaults when no theme; published theme settings win.

## Rules
- Livewire v4 conventions from the skill (full-page components, layouts). Keep components in app/Livewire/Storefront/... with matching views.
- All visible strings English. All prices via the Money helper.
- Dark mode classes on all views. Responsive (mobile-first).
- php artisan test --compact must pass fully (78 existing + new). vendor/bin/pint --dirty --format agent at the end. npm run build must succeed.
- Do NOT git commit. Do NOT modify specs/. Do not create documentation files.

REPORT BACK: files created, test results, npm build result, deviations/decisions.


Output:
agent_id: agent-2
actual_subagent_type: coder
status: completed

[summary]
Phase 3 is complete. Full report below.

## Files created

**Models** (`app/Models/`) — `Theme.php` (BelongsToStore, `files()`/`settings()` relations, `publish()` demotes other themes transactionally, `duplicate()` copies files+settings as draft), `ThemeFile.php`, `ThemeSettings.php` (PK `theme_id`, `settings_json=>array`), `Page.php` (auto-handle via `HandleGenerator` on creating when blank, `SanitizeHtml` on saving, `scopePublished()`), `NavigationMenu.php`, `NavigationItem.php` (both flush nav cache in `booted()`).

**Factories** (`database/factories/`) — `ThemeFactory` (+`published()`), `ThemeFileFactory`, `ThemeSettingsFactory`, `PageFactory` (+`published()`), `NavigationMenuFactory` (+`mainMenu()`/`footerMenu()`), `NavigationItemFactory`.

**Services** — `app/Services/NavigationService.php`: `forHandle()` (cached 5 min, key `nav:{store_id}:{handle}`), `buildTree()`, `resolveUrl()` (link→url, page/collection/product→`/{pages|collections|products}/{handle}`, missing resource→`#`, bulk-prefetches handles to avoid N+1), `invalidate()`. `app/Services/ThemeSettingsService.php`: singleton, 5-min cache per store, `get()`/`all()`, DEFAULTS (announcement, header, colors, dark_mode, sections_order, hero, featured_collections/products, newsletter, rich_text, footer, seo), published theme's `settings_json` merged via `array_replace_recursive`, `invalidate()` hooked from Theme/ThemeSettings `booted()`.

**Support** — `app/Support/Money.php`: `format(int $cents, string $currency)` per spec (`.` decimals, `,` thousands, code after amount): `24.99 EUR`, `0.00 EUR`, `1,499.00 EUR`, `-12.50 EUR`.

**Views** — `resources/views/storefront/layouts/app.blade.php` (skip link, announcement bar with localStorage dismiss, header with logo/main-menu nav/search/account/cart-badge, mobile drawer, footer with footer-menu + store info + payment placeholder + Organization JSON-LD, Alpine cart-drawer shell opening on `cart-drawer-open` with "Cart is empty", dark-mode class script driven by theme setting, canonical/OG/meta-description support via layout params). Components in `resources/views/storefront/components/` registered as `<x-storefront::*>`: `product-card`, `price`, `badge`, `quantity-selector`, `breadcrumbs` (with BreadcrumbList JSON-LD), `pagination` (windowed numbered + prev/next + mobile indicator). Livewire views in `resources/views/livewire/storefront/`: `home`, `collections/index`, `collections/show` (+ `partials/filters`), `products/show`, `pages/show`.

**Livewire components** (`app/Livewire/Storefront/`) — `Home` (sections ordered/toggled by theme settings, graceful fallbacks), `Collections\Index`, `Collections\Show` (12/page, sort featured/price-asc/price-desc/newest/best-selling, filters: availability, min/max price, product_type, vendor, active pills + clear-all, desktop sidebar + mobile drawer), `Products\Show` (gallery with thumbs/mobile snap-scroll, option pills ≤6 / select >6 / Color swatches, per-value availability, stock messaging per policy, quantity selector, `addToCart` dispatches browser event `add-to-cart` with `variantId`+`quantity`, Product JSON-LD, OG tags, 404 for non-visible), `Pages\Show` (published only, 404 otherwise).

**Modified** — `routes/web.php` (placeholder replaced with 5 `Route::livewire` storefront routes), `app/Providers/AppServiceProvider.php` (singleton + `Blade::anonymousComponentPath(..., 'storefront')`), `resources/css/app.css` (`[x-cloak]` + `.storefront-prose` styles — the typography plugin isn't installed, so prose is hand-rolled). `routes/admin.php`, `routes/api.php`, `specs/` untouched.

## Verification
- `php artisan test --compact`: **105 passed (289 assertions)** — 78 pre-existing + 27 new (`tests/Feature/Storefront/BrowsingTest.php` 17 tests, `ThemeSettingsTest.php` 6, `tests/Unit/MoneyTest.php` 5). Covers: home 200 + product title/price, unknown/draft product 404, collections index active-only, collection show visible-products-only + pagination 12+3, variant options render, sold-out (deny) vs backorder (continue), `addToCart` event payload, published/draft pages, handle auto-generation, nav tree/resolveUrl/header labels/cache invalidation, Money formatting, theme-settings defaults/override/draft-ignored/cache invalidation/publish/duplicate.
- `npm run build`: succeeds (Vite 7, Tailwind v4, 215 kB CSS).
- `vendor/bin/pint --dirty --format agent`: clean (removed one unused import).
- No git commit made.

## Deviations/decisions
- **Newsletter section** is client-side only (Alpine success toggle) — no subscriber storage exists yet; noted in a comment for a later phase.
- **Class-based Livewire components** (task requirement) with `->layout('storefront.layouts.app', [...])->title(...)` in `render()` — verified against Livewire v4's `SupportPageComponents` internals; `Route::livewire()` used per the v4 skill.
- **Navigation tree is flat** (empty `children` per item): `navigation_items` has no `parent_id` column in the migrated schema, so one level of items with a forward-compatible `children` key.
- **Pagination uses plain href links** (`?page=N` full reloads) — SEO-friendly and works with `WithPagination`; filter/sort changes reset the page.
- Product card wraps only image/title/price in the card link (quick-add sits outside the anchor) to avoid invalid nested interactive elements.


Phase 3 done — 105 tests. Let me verify tests pass and the storefront renders in a real browser quickly (smoke check). Then commit and launch Phase 4.

Actually, a quick smoke: the dev DB has no seed data yet. Let me create a quick throwaway store+products via tinker to see the storefront render at acme-fashion.test. That validates the whole stack end-to-end early. Worth it.


Phase 3 reports 105 passing tests. Let me verify and smoke-test the storefront in the real environment.


Bash

php artisan test --compact 2>&1 | tail -4 && php artisan migrate:fresh --force 2>&1 | tail -2

Running: php artisan test --compact 2>&1 | tail -4 && php a…


Output:
  Tests:    105 passed (289 assertions)
  Duration: 1.91s

  2026_01_01_000046_create_customer_password_reset_tokens_table .. 0.62ms DONE


php artisan tinker --execute '
$org = App\Models\Organization::factory()->create(["name"=>"Acme Corp","billing_email"=>"billing@acme.test"]);
$store = App\Models\Store::factory()->create(["organization_id"=>$org->id,"name"=>"Acme Fashion","handle"=>"acme-fashion","default_currency"=>"EUR"]);
App\Models\StoreDomain::factory()->create(["store_id"=>$store->id,"hostname"=>"acme-fashion.test","is_primary"=>true]);
app()->instance("current_store", $store);
$theme = App\Models\Theme::factory()->published()->create(["store_id"=>$store->id,"name"=>"Base"]);
App\Models\ThemeSettings::factory()->create(["theme_id"=>$theme->id]);
$col = App\Models\Collection::factory()->create(["store_id"=>$store->id,"title"=>"T-Shirts","handle"=>"t-shirts","status"=>"active"]);
$p = App\Models\Product::factory()->active()->create(["store_id"=>$store->id,"title"=>"Classic Cotton T-Shirt","handle"=>"classic-cotton-t-shirt","published_at"=>now()]);
$v = App\Models\ProductVariant::factory()->create(["product_id"=>$p->id,"price_amount"=>2499,"is_default"=>true]);
App\Models\InventoryItem::factory()->create(["store_id"=>$store->id,"variant_id"=>$v->id,"quantity_on_hand"=>50]);
$p->collections()->attach($col->id, ["position"=>0]);
$page = App\Models\Page::factory()->published()->create(["store_id"=>$store->id,"title"=>"About","handle"=>"about"]);
echo "seeded store {$store->id}";' 2>&1 | tail -3

Running: php artisan tinker --execute '
$org = App\Models\O…


Output: seeded store 1


curl -s -o /dev/null -w "home: %{http_code}\n" http://acme-fashion.test/ && curl -s -o /dev/null -w "product: %{http_code}\n" http://acme-fashion.test/products/classic-cotton-t-shirt && curl -s -o /dev/null -w "collections: %{http_code}\n" http://acme-fashion.test/collections && curl -s -o /dev/null -w "collection: %{http_code}\n" http://acme-fashion.test/collections/t-shirts && curl -s -o /dev/null -w "page: %{http_code}\n" http://acme-fashion.test/pages/about && curl -s -o /dev/null -w "404check: %{http_code}\n" http://acme-fashion.test/products/nope && curl -s -o /dev/null -w "electronics(no-domain): %{http_code}\n" http://acme-electronics.test/

Running: curl -s -o /dev/null -w "home: %{http_code}\n" htt…


Output:
home: 200
product: 200
collections: 200
collection: 200
page: 200
404check: 404
electronics(no-domain): 404


The storefront works in the real environment. Electronics 404s because no domain record exists for acme-electronics.test yet — correct behavior (Phase 11 seeders will add it).

Let me commit Phase 3 and launch Phase 4 (Cart, Checkout, Discounts, Shipping, Taxes — the biggest service-heavy phase).


Storefront renders correctly in the real environment. Committing Phase 3 and launching Phase 4 (cart/checkout/pricing — the biggest service phase).


python3 - <<'EOF'
p = 'specs/progress.md'
s = open(p).read()
section = s.split('### Phase 3 — Themes')[1].split('### Phase 4')[0]
s = s.replace(section, section.replace('- [ ]', '- [x]'))
s = s.replace('## Phase Checklist', '| 3 | 2026-07-19 | Phase 3 | Themes/pages/navigation models + NavigationService + ThemeSettingsService + Money helper; storefront layout (dark mode, a11y) + components; Home/Collections/Products/Pages Livewire; real-env smoke OK (200s + 404). 105 tests green. | pending |\n\n## Phase Checklist', 1)
open(p, 'w').write(s)
print('ok')
EOF
git add -A && git commit -q -m "Phase 3: Themes, pages, navigation + storefront layout and browse experience

- Theme/ThemeFile/ThemeSettings/Page/NavigationMenu/NavigationItem models
- NavigationService (cached trees, URL resolution), ThemeSettingsService
- Money formatting helper per spec
- Storefront layout: announcement bar, header nav, footer, cart drawer shell, dark mode, skip links
- Livewire storefront: Home, Collections Index/Show (filters/sort/pagination), Products Show (gallery, variant picker, stock states), Pages Show
- Blade components: product-card, price, badge, quantity-selector, breadcrumbs, pagination
- 27 new tests: 105 passed total; npm build green; real-env smoke test OK" && git log --oneline -1


Output:
ok
34ac3644 Phase 3: Themes, pages, navigation + storefront layout and browse experience


Now Phase 4 — the biggest service phase: Cart, Checkout, Discounts, Shipping, Taxes. This includes:
- Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
- Services: CartService, DiscountService, ShippingCalculator, TaxCalculator (+ ManualTaxProvider, StripeTaxProvider stub), PricingEngine, CheckoutService
- Value objects: PricingResult, TaxLine, TaxCalculationRequest/Result, DiscountValidationResult, Address, ShippingRate VO
- Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts
- Events: CheckoutAddressed, CheckoutShippingSelected, CheckoutCompleted, CheckoutExpired
- Storefront cart UI: CartDrawer (real), Cart\Show page
- Checkout UI: Checkout\Show (multi-step), Confirmation — wait, confirmation needs orders (Phase 5). The checkout completion (payment) is Phase 5. Hmm, phase boundaries: spec 09 Phase 4 includes checkout UI (multi-step Contact→Shipping→Payment). Phase 5 is payments/orders/fulfillment.

To keep phases coherent and avoid rework, I'll structure:
- Phase 4: models + value objects + all calculation services (PricingEngine, Discount, Shipping, Tax) + CartService + CheckoutService state machine up to payment_selected + REST API cart endpoints + checkout endpoints (except /pay) + cart UI + checkout UI steps 1-2 (contact/address/shipping) + discount application + unit/feature tests. The pay step UI will come with Phase 5 (payment methods + completion + confirmation page).
- Phase 5: payments/orders/fulfillment/customers + pay endpoint + checkout UI payment step + confirmation + tests.

The checkout UI being split across two phases is slightly awkward but fine — Phase 5 completes it.

Unit tests from spec 09: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest. Feature: CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest.

The spec 09 has detailed test tables (I saw the beginning — e.g. "calculates subtotal from line items | 2 lines: variant A 2499 x2, B 7999 x1 | subtotal = 12997"). The subagent should read specs/09-IMPLEMENTATION-ROADMAP.md §3 test specs lines 1000-2065 to implement tests per the exact expected values. That's important. Also spec 02 §2.1-2.2 (cart/checkout API schemas) and spec 05 §4-9 (cart, pricing, checkout, discount, tax, shipping).

This is a very big phase; it might exceed one subagent's 30-min window. Let me split:
- Phase 4a: models + value objects + calculation services + unit tests + CartService + cart API + cart feature tests
- Phase 4b: CheckoutService + checkout API + checkout feature tests + cart/checkout Livewire UI

Actually, Phase 4a alone is huge (7 models, 5 services, 5 VOs, cart API + tests). Hmm. The subagents so far handled big scopes well (Phase 2 did 7 models + 4 services + 63 tests). Let me try Phase 4 as one agent but with clear priority order, and if it times out, resume it. Actually the timeout guidance says resume on timeout. OK.

Let me write the Phase 4 prompt.


Agent

Phase 4: Cart/checkout engine

Prompt:
You are building PHASE 4 (Cart, Checkout, Discounts, Shipping, Taxes) of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, Livewire v4, Flux UI Free v2, Tailwind v4, SQLite, Pest v4.

CONTEXT (already built):
- Phase 1: all 46 tables migrated (carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts exist); 28 enums (CartStatus, CheckoutStatus incl. payment_selected, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode); BelongsToStore trait + StoreScope; ResolveStore middleware; rate limiters (api.storefront 120/min IP, checkout 10/min session); routes/web.php has storefront Livewire routes; routes/api.php has an empty /api/storefront/v1 group with middleware [store.resolve:storefront, throttle:api.storefront]; tests/TestCase.php helpers (createStore, bindStore); RefreshDatabase for Feature tests.
- Phase 2: Product/ProductVariant/InventoryItem/Collection models + factories; InventoryService (reserve/release/commit/restock, throws InsufficientInventoryException); ProductVariant has inventoryItem(), price_amount, weight_g, requires_shipping, title() helper ("Blue / Medium").
- Phase 3: storefront layout + Money helper (App\Support\Money::format), <x-storefront::*> Blade components, Alpine cart drawer shell in the layout (opens on browser event 'cart-drawer-open'), Products\Show dispatches browser event 'add-to-cart' with {variantId, quantity}.
- Customer model exists (minimal). Sanctum installed.

MANDATORY first steps:
1. Read specs/05-BUSINESS-LOGIC.md sections 4 (Cart), 5 (Pricing Engine), 6 (Checkout State Machine), 7 (Discount Engine), 8 (Tax), 9 (Shipping), 22 (Value Object Registry)
2. Read specs/02-API-ROUTES.md sections 2.1 (Cart endpoints) and 2.2 (Checkout endpoints) — implement request/response schemas EXACTLY (validation rules, status codes incl. 409 version conflicts, 410 expired, error_code payloads)
3. Read specs/09-IMPLEMENTATION-ROADMAP.md Phase 4 (lines ~442-597) AND the test specification tables in section 3 for: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest (they contain EXACT setup values and expected numbers — implement tests to match; they are roughly lines 1007-2065 of that file)
4. Read specs/04-STOREFRONT-UI.md sections 6 (Cart Drawer), 7 (Full Cart Page), 8 (Checkout Page) for the UI
5. Activate skills: .agents/skills/laravel-best-practices/SKILL.md, .agents/skills/pest-testing/SKILL.md, .agents/skills/livewire-development/SKILL.md (read and follow)

BUILD (in this order):

## A. Models + factories
Cart (BelongsToStore; belongsTo Customer nullable; hasMany CartLine; hasMany Checkout; casts status=>CartStatus; helpers: subtotal(), itemCount(), lineCount(), recalculateLines()), CartLine (belongsTo Cart, belongsTo ProductVariant as variant; recalculate() sets line_subtotal/total; unique(cart_id,variant_id) already in schema), Checkout (BelongsToStore; belongsTo Cart/Customer; casts status=>CheckoutStatus, payment_method=>PaymentMethod, shipping_address_json/billing_address_json/totals_json/tax_provider_snapshot_json=>array, expires_at=>datetime; helpers requiresShipping(), isExpired()), ShippingZone (BelongsToStore; hasMany ShippingRate; casts countries_json/regions_json=>array), ShippingRate (belongsTo ShippingZone; casts config_json=>array, is_active=>bool, type=>ShippingRateType), TaxSettings (PK store_id, $incrementing=false; belongsTo Store; casts config_json=>array, prices_include_tax=>bool, mode=>TaxMode), Discount (BelongsToStore; casts type=>DiscountType, value_type=>DiscountValueType, status=>DiscountStatus, rules_json=>array, starts_at/ends_at=>datetime; scopeActive). Factories for all (CartFactory, CartLineFactory, CheckoutFactory, ShippingZoneFactory, ShippingRateFactory with flat/weight/price states, TaxSettingsFactory, DiscountFactory with percent/fixed/free_shipping/expired/maxed-out states).

## B. Value objects (app/ValueObjects)
Per spec 05 §22: PricingResult (subtotal, discount, shipping, taxLines: TaxLine[], taxTotal, total, currency — with toArray() mirroring the totals_json snapshot structure {subtotal, discount, shipping, tax, total, currency}), TaxLine (name, rate bps, amount), TaxCalculationRequest (lineItems, shippingAmount, address, taxSettings), TaxCalculationResult (taxLines, totalAmount), DiscountValidationResult (valid, ?discount, ?errorCode, ?errorMessage), Address (all fields per spec, fromArray/toArray), ShippingRateOption VO? (spec 05 §22 lists ShippingRate VO: id, name, amount, type) — name it ShippingRateData to avoid clashing with the Eloquent ShippingRate model? DECISION: name value objects exactly per spec but put the VO as App\ValueObjects\ShippingRate and reference the model via FQCN where needed. Actually cleaner: App\ValueObjects\ShippingRateVO. Choose ShippingRateVO and note it.

## C. Calculation services
- App\Services\TaxCalculator + App\Contracts\TaxProvider + App\Services\Tax\ManualTaxProvider + App\Services\Tax\StripeTaxProvider (stub) per spec 05 §8: integer math only; extractInclusive uses intdiv(gross*10000, 10000+rateBps); addExclusive uses ROUND(net*rate/10000) per line independently then sum; manual rate from tax_settings.config_json['default_rate_bps'] (allow per-zone override via config_json['zone_rates'][zoneId] if present — match zone via same algorithm as shipping); prices_include_tax handling exactly per spec; StripeTaxProvider is a stub returning zero-tax result (with config fallback block/allow semantics documented in code).
- App\Services\ShippingCalculator per spec 05 §9: getMatchingZone (specificity country+region=2 > country=1, tie-break lowest zone id), getAvailableRates(Store, address) → collection of ShippingRateVO with calculated amounts (flat: config amount; weight: ranges by total weight of shippable lines; price: ranges by cart subtotal, missing max_amount = open-ended; carrier: stub null), calculate(rate, cart): ?int (null when range doesn't match).
- App\Services\DiscountService per spec 05 §7: validate(code, store, cart) → DiscountValidationResult with the exact error codes (discount_not_found, discount_expired, discount_not_yet_active, discount_usage_limit_reached, discount_min_purchase_not_met, discount_not_applicable); case-insensitive code lookup; calculate(discount, subtotal, lines) allocating proportionally with largest-remainder on the last qualifying line (spec §7.6); applicability union of product_ids/collection_ids rules; free_shipping type handled by PricingEngine (zero shipping). Also automatic discounts: getApplicableAutomaticDiscounts(store, cart) evaluated each calculation, stacking sequentially on remaining undiscounted amount.
- App\Services\PricingEngine per spec 05 §5: calculate(lines, ?Discount $codeDiscount, array $automaticDiscounts, ?ShippingRateVO $shippingRate, TaxSettings $taxSettings, ?Address $address): PricingResult — deterministic 7-step pipeline; per-line discount allocation (largest remainder); tax on discounted amounts (+shipping if taxable — treat shipping as taxable at the same rate); free_shipping discount zeroes shipping; tax-inclusive extraction per spec §8.5 (discount subtracted from gross, tax extracted from post-discount gross). Snapshot toArray matches checkouts.totals_json.

## D. CartService (spec 05 §4)
create(store, ?customer), addLine (validations in spec order: variant exists in store, product active, variant active, inventory policy, merge existing line by incrementing, unit price from variant, recalc amounts, cart_version++), updateLineQuantity (0 removes; inventory recheck), removeLine, getOrCreateForSession(store, ?customer) using session('cart_id'), mergeOnLogin(guest, customer) per spec pseudocode (prefer higher qty for duplicates, mark guest abandoned). All mutations increment cart_version. Expect/verify version: add a method assertVersion(Cart, int $expectedVersion) throwing a CartVersionMismatchException (HTTP 409 mapped in API).
App\Exceptions\CartVersionMismatchException carrying the current cart.

## E. Storefront REST API (routes/api.php, /api/storefront/v1)
Controllers in app/Http/Controllers/Api/Storefront: CartController (create, show, addLine, updateLine, removeLine) + CheckoutController (create, show, setAddress, setShippingMethod, selectPaymentMethod, applyDiscount, removeDiscount) per spec 02 §2.1-2.2 EXACTLY:
- Form Requests per spec 06 §4.4 where applicable (SetCheckoutAddressRequest, ApplyDiscountRequest) in app/Http/Requests.
- Cart resource JSON shape exactly like spec (lines with product_title/variant_title/sku/available_quantity, totals with line_count/item_count).
- Version conflict → 409 with current cart in body. Invalid variant/stock → 422 with field errors.
- Checkout create: cart must be active with >=1 line; checkout expires_at = now+24h; checkout JSON shape per spec (lines, totals, available_shipping_methods, expires_at).
- setAddress: validate per spec fields; copy shipping→billing when use_shipping_as_billing; if no shipping zone matches → 422 "Cannot ship to this address"; transition to addressed; recalc totals; response includes available_shipping_methods with {id, name, type, price_amount, currency, estimated_days_min/max (null ok)}.
- setShippingMethod: rate must be among available for the address; transition shipping_selected; recalc incl. shipping+tax; tax_provider_snapshot_json per spec shape.
- selectPaymentMethod: only from shipping_selected; reserve inventory for all lines; transition payment_selected.
- applyDiscount/removeDiscount: validate via DiscountService; recalc; errors with error_code payloads per spec (400 for expired/usage, 422 invalid).
- GET checkout: 410 when expired.
- Skip the /pay endpoint and order endpoints (Phase 5).
- Rate limit checkout routes with throttle:checkout on top.

## F. CheckoutService state machine (spec 05 §6)
setAddress, setShippingMethod (handles no-shipping-required skip: status shipping_selected with shipping_method_id null + shipping 0), selectPaymentMethod (reserves inventory, sets expires_at), expireCheckout (releases reserved inventory, status expired), recalculate(Checkout): PricingResult (persists totals_json + per-line discount amounts on cart_lines, stores tax snapshot). Dispatch events CheckoutAddressed/CheckoutShippingSelected/CheckoutExpired (create in app/Events). completeCheckout is Phase 5 — do NOT implement.

## G. Jobs + scheduling
App\Jobs\ExpireAbandonedCheckouts (finds active checkouts with updated_at < now-24h OR expires_at < now, releases inventory when payment_selected, expires them) and App\Jobs\CleanupAbandonedCarts (active carts with updated_at < now-14 days → abandoned). Register schedules in routes/console.php: expire checkouts every 15 minutes, cleanup carts daily.

## H. Storefront cart/checkout UI
- App\Livewire\Storefront\CartDrawer component replacing the shell content in the layout (per spec 04 §6): line items with images/qty controls/remove, discount code input, subtotal, checkout button, empty state; listens for 'add-to-cart' browser event → adds line via CartService (getOrCreateForSession), refreshes, opens drawer; dispatches updated count to header badge (browser event 'cart-updated' with count; update layout badge to listen). Keep the layout's drawer container and swap its inner static content for <livewire:storefront.cart-drawer />.
- App\Livewire\Storefront\Cart\Show at GET /cart (per spec 04 §7): full page, same features + order summary with shipping estimate note.
- App\Livewire\Storefront\Checkout\Show at GET /checkout/{checkoutId} (per spec 04 §8): stepper UI with steps Contact/Address → Shipping → Payment. Implement steps 1-2 fully (email+address form with validation, shipping method selection recalculating totals via CheckoutService); step 3 (Payment) renders method radio selection (credit_card/paypal/bank_transfer) + selectPaymentMethod wiring, with the actual pay button rendering a disabled/placeholder state + note (Phase 5 implements payment). Also handle expired checkout (410-style message), empty-cart checkout creation entry point: "Proceed to checkout" from cart creates checkout via POST to the API internally? DECISION: in Cart UI call CheckoutService directly (create checkout from session cart with email collected at step 1 — allow email entry on the checkout page itself; create checkout with placeholder email? The API requires email at creation. For the UI flow: create checkout when the user lands on /checkout without one — collect email in step 1 BEFORE creating? Simplest faithful-to-spec approach: checkout is created via CheckoutService::createFromCart(cart, email) where email comes from the step-1 form; the checkout page route takes checkoutId; the cart's "checkout" button posts to a Livewire action that either reuses an existing active checkout for the session cart or redirects to a small email-capture state? NO — simpler: cart checkout button redirects to /checkout/new; Checkout\Show with id 'new' renders step 1 (email+address); on submit creates checkout (status started→addressed) and redirects to /checkout/{id} step 2. Choose this and note it.
- Update the add-to-cart flow end-to-end: Products\Show addToCart should ALSO call into CartService server-side (not just dispatch an event) — refactor: Products\Show::addToCart() adds the line via CartService then dispatches 'cart-updated' + opens drawer. (The Phase 3 placeholder dispatched only a browser event; make it real now.)
- tests for UI: Livewire::test on CartDrawer add line, Cart\Show quantity update, Checkout\Show step 1→2 transitions.

## I. Tests (Pest) — per spec 09 §3 exact tables
- tests/Unit/PricingEngineTest.php, tests/Unit/DiscountCalculatorTest.php, tests/Unit/TaxCalculatorTest.php, tests/Unit/ShippingCalculatorTest.php, tests/Unit/CartVersionTest.php — implement EVERY row of the spec 09 tables for these files (exact setup + expected values).
- tests/Feature/Cart/CartServiceTest.php, tests/Feature/Cart/CartApiTest.php
- tests/Feature/Checkout/CheckoutFlowTest.php, CheckoutStateTest.php, PricingIntegrationTest.php, DiscountTest.php, ShippingTest.php, TaxTest.php
Follow the spec tables where present; otherwise cover the spec behaviors (state transitions incl. invalid ones, 409/410/422 responses, discount error codes, zone matching tie-breaks, weight/price ranges, inclusive/exclusive tax, free-shipping override, automatic discounts stacking).

## Rules
- Integer cents everywhere; no floats. Deterministic rounding per spec.
- php artisan test --compact must pass fully (105 existing + new). vendor/bin/pint --dirty --format agent at the end.
- Do NOT git commit. Do NOT modify specs/. Do not create documentation files. Keep existing tests green.
- If a spec 09 test table contradicts another spec, follow spec 09 and note it.

REPORT BACK: files created, test results (counts), deviations/decisions.

Launching coder agent: Phase 4: Cart/checkout engine

Prompt:
You are building PHASE 4 (Cart, Checkout, Discounts, Shipping, Taxes) of a multi-tenant e-commerce platform in a Laravel 12 project at /Users/fabianwesner/Herd/shop. PHP 8.4, Livewire v4, Flux UI Free v2, Tailwind v4, SQLite, Pest v4.

CONTEXT (already built):
- Phase 1: all 46 tables migrated (carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts exist); 28 enums (CartStatus, CheckoutStatus incl. payment_selected, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode); BelongsToStore trait + StoreScope; ResolveStore middleware; rate limiters (api.storefront 120/min IP, checkout 10/min session); routes/web.php has storefront Livewire routes; routes/api.php has an empty /api/storefront/v1 group with middleware [store.resolve:storefront, throttle:api.storefront]; tests/TestCase.php helpers (createStore, bindStore); RefreshDatabase for Feature tests.
- Phase 2: Product/ProductVariant/InventoryItem/Collection models + factories; InventoryService (reserve/release/commit/restock, throws InsufficientInventoryException); ProductVariant has inventoryItem(), price_amount, weight_g, requires_shipping, title() helper ("Blue / Medium").
- Phase 3: storefront layout + Money helper (App\Support\Money::format), <x-storefront::*> Blade components, Alpine cart drawer shell in the layout (opens on browser event 'cart-drawer-open'), Products\Show dispatches browser event 'add-to-cart' with {variantId, quantity}.
- Customer model exists (minimal). Sanctum installed.

MANDATORY first steps:
1. Read specs/05-BUSINESS-LOGIC.md sections 4 (Cart), 5 (Pricing Engine), 6 (Checkout State Machine), 7 (Discount Engine), 8 (Tax), 9 (Shipping), 22 (Value Object Registry)
2. Read specs/02-API-ROUTES.md sections 2.1 (Cart endpoints) and 2.2 (Checkout endpoints) — implement request/response schemas EXACTLY (validation rules, status codes incl. 409 version conflicts, 410 expired, error_code payloads)
3. Read specs/09-IMPLEMENTATION-ROADMAP.md Phase 4 (lines ~442-597) AND the test specification tables in section 3 for: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest (they contain EXACT setup values and expected numbers — implement tests to match; they are roughly lines 1007-2065 of that file)
4. Read specs/04-STOREFRONT-UI.md sections 6 (Cart Drawer), 7 (Full Cart Page), 8 (Checkout Page) for the UI
5. Activate skills: .agents/skills/laravel-best-practices/SKILL.md, .agents/skills/pest-testing/SKILL.md, .agents/skills/livewire-development/SKILL.md (read and follow)

BUILD (in this order):

## A. Models + factories
Cart (BelongsToStore; belongsTo Customer nullable; hasMany CartLine; hasMany Checkout; casts status=>CartStatus; helpers: subtotal(), itemCount(), lineCount(), recalculateLines()), CartLine (belongsTo Cart, belongsTo ProductVariant as variant; recalculate() sets line_subtotal/total; unique(cart_id,variant_id) already in schema), Checkout (BelongsToStore; belongsTo Cart/Customer; casts status=>CheckoutStatus, payment_method=>PaymentMethod, shipping_address_json/billing_address_json/totals_json/tax_provider_snapshot_json=>array, expires_at=>datetime; helpers requiresShipping(), isExpired()), ShippingZone (BelongsToStore; hasMany ShippingRate; casts countries_json/regions_json=>array), ShippingRate (belongsTo ShippingZone; casts config_json=>array, is_active=>bool, type=>ShippingRateType), TaxSettings (PK store_id, $incrementing=false; belongsTo Store; casts config_json=>array, prices_include_tax=>bool, mode=>TaxMode), Discount (BelongsToStore; casts type=>DiscountType, value_type=>DiscountValueType, status=>DiscountStatus, rules_json=>array, starts_at/ends_at=>datetime; scopeActive). Factories for all (CartFactory, CartLineFactory, CheckoutFactory, ShippingZoneFactory, ShippingRateFactory with flat/weight/price states, TaxSettingsFactory, DiscountFactory with percent/fixed/free_shipping/expired/maxed-out states).

## B. Value objects (app/ValueObjects)
Per spec 05 §22: PricingResult (subtotal, discount, shipping, taxLines: TaxLine[], taxTotal, total, currency — with toArray() mirroring the totals_json snapshot structure {subtotal, discount, shipping, tax, total, currency}), TaxLine (name, rate bps, amount), TaxCalculationRequest (lineItems, shippingAmount, address, taxSettings), TaxCalculationResult (taxLines, totalAmount), DiscountValidationResult (valid, ?discount, ?errorCode, ?errorMessage), Address (all fields per spec, fromArray/toArray), ShippingRateOption VO? (spec 05 §22 lists ShippingRate VO: id, name, amount, type) — name it ShippingRateData to avoid clashing with the Eloquent ShippingRate model? DECISION: name value objects exactly per spec but put the VO as App\ValueObjects\ShippingRate and reference the model via FQCN where needed. Actually cleaner: App\ValueObjects\ShippingRateVO. Choose ShippingRateVO and note it.

## C. Calculation services
- App\Services\TaxCalculator + App\Contracts\TaxProvider + App\Services\Tax\ManualTaxProvider + App\Services\Tax\StripeTaxProvider (stub) per spec 05 §8: integer math only; extractInclusive uses intdiv(gross*10000, 10000+rateBps); addExclusive uses ROUND(net*rate/10000) per line independently then sum; manual rate from tax_settings.config_json['default_rate_bps'] (allow per-zone override via config_json['zone_rates'][zoneId] if present — match zone via same algorithm as shipping); prices_include_tax handling exactly per spec; StripeTaxProvider is a stub returning zero-tax result (with config fallback block/allow semantics documented in code).
- App\Services\ShippingCalculator per spec 05 §9: getMatchingZone (specificity country+region=2 > country=1, tie-break lowest zone id), getAvailableRates(Store, address) → collection of ShippingRateVO with calculated amounts (flat: config amount; weight: ranges by total weight of shippable lines; price: ranges by cart subtotal, missing max_amount = open-ended; carrier: stub null), calculate(rate, cart): ?int (null when range doesn't match).
- App\Services\DiscountService per spec 05 §7: validate(code, store, cart) → DiscountValidationResult with the exact error codes (discount_not_found, discount_expired, discount_not_yet_active, discount_usage_limit_reached, discount_min_purchase_not_met, discount_not_applicable); case-insensitive code lookup; calculate(discount, subtotal, lines) allocating proportionally with largest-remainder on the last qualifying line (spec §7.6); applicability union of product_ids/collection_ids rules; free_shipping type handled by PricingEngine (zero shipping). Also automatic discounts: getApplicableAutomaticDiscounts(store, cart) evaluated each calculation, stacking sequentially on remaining undiscounted amount.
- App\Services\PricingEngine per spec 05 §5: calculate(lines, ?Discount $codeDiscount, array $automaticDiscounts, ?ShippingRateVO $shippingRate, TaxSettings $taxSettings, ?Address $address): PricingResult — deterministic 7-step pipeline; per-line discount allocation (largest remainder); tax on discounted amounts (+shipping if taxable — treat shipping as taxable at the same rate); free_shipping discount zeroes shipping; tax-inclusive extraction per spec §8.5 (discount subtracted from gross, tax extracted from post-discount gross). Snapshot toArray matches checkouts.totals_json.

## D. CartService (spec 05 §4)
create(store, ?customer), addLine (validations in spec order: variant exists in store, product active, variant active, inventory policy, merge existing line by incrementing, unit price from variant, recalc amounts, cart_version++), updateLineQuantity (0 removes; inventory recheck), removeLine, getOrCreateForSession(store, ?customer) using session('cart_id'), mergeOnLogin(guest, customer) per spec pseudocode (prefer higher qty for duplicates, mark guest abandoned). All mutations increment cart_version. Expect/verify version: add a method assertVersion(Cart, int $expectedVersion) throwing a CartVersionMismatchException (HTTP 409 mapped in API).
App\Exceptions\CartVersionMismatchException carrying the current cart.

## E. Storefront REST API (routes/api.php, /api/storefront/v1)
Controllers in app/Http/Controllers/Api/Storefront: CartController (create, show, addLine, updateLine, removeLine) + CheckoutController (create, show, setAddress, setShippingMethod, selectPaymentMethod, applyDiscount, removeDiscount) per spec 02 §2.1-2.2 EXACTLY:
- Form Requests per spec 06 §4.4 where applicable (SetCheckoutAddressRequest, ApplyDiscountRequest) in app/Http/Requests.
- Cart resource JSON shape exactly like spec (lines with product_title/variant_title/sku/available_quantity, totals with line_count/item_count).
- Version conflict → 409 with current cart in body. Invalid variant/stock → 422 with field errors.
- Checkout create: cart must be active with >=1 line; checkout expires_at = now+24h; checkout JSON shape per spec (lines, totals, available_shipping_methods, expires_at).
- setAddress: validate per spec fields; copy shipping→billing when use_shipping_as_billing; if no shipping zone matches → 422 "Cannot ship to this address"; transition to addressed; recalc totals; response includes available_shipping_methods with {id, name, type, price_amount, currency, estimated_days_min/max (null ok)}.
- setShippingMethod: rate must be among available for the address; transition shipping_selected; recalc incl. shipping+tax; tax_provider_snapshot_json per spec shape.
- selectPaymentMethod: only from shipping_selected; reserve inventory for all lines; transition payment_selected.
- applyDiscount/removeDiscount: validate via DiscountService; recalc; errors with error_code payloads per spec (400 for expired/usage, 422 invalid).
- GET checkout: 410 when expired.
- Skip the /pay endpoint and order endpoints (Phase 5).
- Rate limit checkout routes with throttle:checkout on top.

## F. CheckoutService state machine (spec 05 §6)
setAddress, setShippingMethod (handles no-shipping-required skip: status shipping_selected with shipping_method_id null + shipping 0), selectPaymentMethod (reserves inventory, sets expires_at), expireCheckout (releases reserved inventory, status expired), recalculate(Checkout): PricingResult (persists totals_json + per-line discount amounts on cart_lines, stores tax snapshot). Dispatch events CheckoutAddressed/CheckoutShippingSelected/CheckoutExpired (create in app/Events). completeCheckout is Phase 5 — do NOT implement.

## G. Jobs + scheduling
App\Jobs\ExpireAbandonedCheckouts (finds active checkouts with updated_at < now-24h OR expires_at < now, releases inventory when payment_selected, expires them) and App\Jobs\CleanupAbandonedCarts (active carts with updated_at < now-14 days → abandoned). Register schedules in routes/console.php: expire checkouts every 15 minutes, cleanup carts daily.

## H. Storefront cart/checkout UI
- App\Livewire\Storefront\CartDrawer component replacing the shell content in the layout (per spec 04 §6): line items with images/qty controls/remove, discount code input, subtotal, checkout button, empty state; listens for 'add-to-cart' browser event → adds line via CartService (getOrCreateForSession), refreshes, opens drawer; dispatches updated count to header badge (browser event 'cart-updated' with count; update layout badge to listen). Keep the layout's drawer container and swap its inner static content for <livewire:storefront.cart-drawer />.
- App\Livewire\Storefront\Cart\Show at GET /cart (per spec 04 §7): full page, same features + order summary with shipping estimate note.
- App\Livewire\Storefront\Checkout\Show at GET /checkout/{checkoutId} (per spec 04 §8): stepper UI with steps Contact/Address → Shipping → Payment. Implement steps 1-2 fully (email+address form with validation, shipping method selection recalculating totals via CheckoutService); step 3 (Payment) renders method radio selection (credit_card/paypal/bank_transfer) + selectPaymentMethod wiring, with the actual pay button rendering a disabled/placeholder state + note (Phase 5 implements payment). Also handle expired checkout (410-style message), empty-cart checkout creation entry point: "Proceed to checkout" from cart creates checkout via POST to the API internally? DECISION: in Cart UI call CheckoutService directly (create checkout from session cart with email collected at step 1 — allow email entry on the checkout page itself; create checkout with placeholder email? The API requires email at creation. For the UI flow: create checkout when the user lands on /checkout without one — collect email in step 1 BEFORE creating? Simplest faithful-to-spec approach: checkout is created via CheckoutService::createFromCart(cart, email) where email comes from the step-1 form; the checkout page route takes checkoutId; the cart's "checkout" button posts to a Livewire action that either reuses an existing active checkout for the session cart or redirects to a small email-capture state? NO — simpler: cart checkout button redirects to /checkout/new; Checkout\Show with id 'new' renders step 1 (email+address); on submit creates checkout (status started→addressed) and redirects to /checkout/{id} step 2. Choose this and note it.
- Update the add-to-cart flow end-to-end: Products\Show addToCart should ALSO call into CartService server-side (not just dispatch an event) — refactor: Products\Show::addToCart() adds the line via CartService then dispatches 'cart-updated' + opens drawer. (The Phase 3 placeholder dispatched only a browser event; make it real now.)
- tests for UI: Livewire::test on CartDrawer add line, Cart\Show quantity update, Checkout\Show step 1→2 transitions.

## I. Tests (Pest) — per spec 09 §3 exact tables
- tests/Unit/PricingEngineTest.php, tests/Unit/DiscountCalculatorTest.php, tests/Unit/TaxCalculatorTest.php, tests/Unit/ShippingCalculatorTest.php, tests/Unit/CartVersionTest.php — implement EVERY row of the spec 09 tables for these files (exact setup + expected values).
- tests/Feature/Cart/CartServiceTest.php, tests/Feature/Cart/CartApiTest.php
- tests/Feature/Checkout/CheckoutFlowTest.php, CheckoutStateTest.php, PricingIntegrationTest.php, DiscountTest.php, ShippingTest.php, TaxTest.php
Follow the spec tables where present; otherwise cover the spec behaviors (state transitions incl. invalid ones, 409/410/422 responses, discount error codes, zone matching tie-breaks, weight/price ranges, inclusive/exclusive tax, free-shipping override, automatic discounts stacking).

## Rules
- Integer cents everywhere; no floats. Deterministic rounding per spec.
- php artisan test --compact must pass fully (105 existing + new). vendor/bin/pint --dirty --format agent at the end.
- Do NOT git commit. Do NOT modify specs/. Do not create documentation files. Keep existing tests green.
- If a spec 09 test table contradicts another spec, follow spec 09 and note it.

REPORT BACK: files created, test results (counts), deviations/decisions.


Output:
agent_id: agent-3
actual_subagent_type: coder
status: completed

[summary]
Phase 4 is complete and fully green. Here's the handoff:

## Test results

`php artisan test --compact`: **219 passed, 757 assertions** (105 pre-existing + 114 new), after which `vendor/bin/pint --dirty --format agent` was run (5 files reformatted, suite re-verified).

New tests per spec 09 §3 tables: Unit — `PricingEngineTest` (14), `DiscountCalculatorTest` (13), `TaxCalculatorTest` (7), `ShippingCalculatorTest` (9), `CartVersionTest` (5). Feature — `Cart/CartServiceTest` (13), `Cart/CartApiTest` (9), `Checkout/CheckoutFlowTest` (5), `Checkout/CheckoutStateTest` (8), `Checkout/PricingIntegrationTest` (5), `Checkout/DiscountTest` (8), `Checkout/ShippingTest` (5), `Checkout/TaxTest` (4), `Storefront/CartUiTest` (9, incl. HTTP smoke + Livewire step transitions).

## Files created

- **Models**: `Cart`, `CartLine`, `Checkout`, `ShippingZone`, `ShippingRate`, `TaxSettings` (PK `store_id`), `Discount` (+7 factories with the required states)
- **Value objects** (`app/ValueObjects/`): `PricingResult` (toArray → totals_json incl. `tax_lines`), `TaxLine`, `TaxCalculationRequest`, `TaxCalculationResult`, `DiscountValidationResult`, `Address`, `ShippingRateVO`
- **Services**: `PricingEngine` (7-step pipeline), `DiscountService` (validate with the 6 spec error codes, largest-remainder allocation, automatic stacking), `ShippingCalculator` (zone specificity 2>1, lowest-id tie-break, flat/weight/price/carrier-stub), `TaxCalculator` + `Contracts/TaxProvider` + `Tax/ManualTaxProvider` (zone_rates override) + `Tax/StripeTaxProvider` (zero-tax stub, block/allow documented), `CartService` (+`assertVersion`), `CheckoutService` (state machine, totals/tax snapshots, per-line discount persistence)
- **API**: `Api/Storefront/CartController` + `CheckoutController` (all spec 02 §2.1/2.2 endpoints), `CartResource`/`CheckoutResource` (unwrapped), `SetCheckoutAddressRequest`, `ApplyDiscountRequest`; routes with `throttle:checkout` layered on checkout group; 409/422 exception renderers in `bootstrap/app.php`
- **Events/Jobs**: `CheckoutAddressed`, `CheckoutShippingSelected`, `CheckoutExpired`; `ExpireAbandonedCheckouts` (15 min), `CleanupAbandonedCarts` (daily, 14-day threshold w/ `store_settings` override), scheduled in `routes/console.php`
- **UI**: `Storefront/CartDrawer`, `Storefront/Cart/Show` (/cart), `Storefront/Checkout/Show` (/checkout/{id}) + 3 Blade views + `Concerns/InteractsWithCart` trait; layout badge listens to `cart-updated`, drawer content swapped for the Livewire component; `Products\Show::addToCart` now writes via CartService

## Deviations & decisions (spec 09 took precedence per instructions)

1. **Tax math uses `intdiv` truncation, not ROUND** — spec 09 requires 629 (8999@7%) and 1044 (5499@19%); spec 05 §8.2 says ROUND (would give 630/1045).
2. **Percent discount total = `intdiv(subtotal×value, 100)`** — spec 09's end-to-end row needs 10% of 4998 = 499 (ROUND gives 500).
3. **Cart merge SUMS duplicate quantities** — spec 09 expects qty 3 for 2+1; spec 05 §4.1 pseudocode (and the task brief) said MAX. Followed spec 09.
4. **Add-line API returns 201** per spec 02's explicit success response; the spec 09 table row says 200.
5. **API discount error codes use spec 05 names** (`discount_usage_limit_reached`); spec 02 §2.2 table calls it `discount_usage_exceeded`.
6. **VO named `ShippingRateVO`** (not spec's "ShippingRate") to avoid clashing with the Eloquent model; extended with nullable `estimatedDaysMin/Max`.
7. **`setAddress` allowed from `shipping_selected`** (resets `shipping_method_id`) — required by spec 09's "recalculates pricing on address change".
8. **Add-line inventory check validates the merged quantity**, not just the requested delta.
9. **Tax = 0 until an address exists** (matches spec 02's checkout-create example showing `tax: 0`).
10. **Cart-page discount codes live in `session('discount_code')`** until checkout creation consumes them.
11. **Phase 5 deliberately excluded**: `completeCheckout`, `/pay`, order endpoints, usage_count increment; spec 09 rows "prevents duplicate orders", "completes full checkout happy path" (order part), and "increments usage count" not implemented; payment step UI renders the disabled placeholder as specified.
12. Small additive change: `BelongsToStore` trait gained a `store()` relation (needed by CheckoutService/Resource).


Phase 4 done — 219 tests passing. Verify and commit, then Phase 5 (Payments, Orders, Fulfillment, Customers).


Output:
  Tests:    219 passed (757 assertions)
  Duration: 3.12s