text:
Script completed
Wall time 0.1 seconds
Output:

text:
Warning: truncated output (original token count: 11208)
Total output lines: 675

37:### Phase Dependency Graph
74:### Phase 1: Foundation (Migrations, Models, Middleware, Auth)
78:#### Step 1.1: Environment and Config
100:#### Step 1.2: Core Migrations (Batch 1-2)
113:#### Step 1.3: Core Models
143:#### Step 1.4: Enums
153:#### Step 1.5: Tenant Resolution Middleware
167:#### Step 1.6: BelongsToStore Trait and Global Scope
178:#### Step 1.7: Authentication
204:#### Step 1.8: Authorization
245:### Phase 2: Catalog (Products, Variants, Inventory, Collections, Media)
249:#### Step 2.1: Migrations (Batch 3-5)
263:#### Step 2.2: Models with Relationships
300:#### Step 2.3: Product Service
323:#### Step 2.4: Inventory Service
337:#### Step 2.5: Media Upload
349:### Phase 3: Themes, Pages, Navigation, Storefront Layout
353:#### Step 3.1: Migrations (Batch 3)
362:#### Step 3.2: Models
394:#### Step 3.3: Storefront Blade Layout
413:#### Step 3.4: Storefront Livewire Components
429:#### Step 3.5: Navigation Service
442:### Phase 4: Cart, Checkout, Discounts, Shipping, Taxes
446:#### Step 4.1: Migrations (Batch 4-5)
456:#### Step 4.2: Models
494:#### Step 4.3: Cart Service
509:#### Step 4.4: Discount Service
520:#### Step 4.5: Shipping Calculator
531:#### Step 4.6: Tax Calculator
543:#### Step 4.7: Pricing Engine
567:#### Step 4.8: Checkout State Machine
586:#### Step 4.9: Storefront Cart/Checkout UI
599:### Phase 5: Payments, Orders, Fulfillment
603:#### Step 5.1: Migrations (Batch 5-7)
614:#### Step 5.2: Models
654:#### Step 5.3: Payment Service (Mock PSP)
680:#### Step 5.3b: Admin Payment Confirmation
696:#### Step 5.4: Order Service
708:#### Step 5.5: Refund Service
720:#### Step 5.6: Fulfillment Service
746:### Phase 6: Customer Accounts
750:#### Step 6.1: Customer Auth
756:#### Step 6.2: Customer Account Pages (Livewire)
773:### Phase 7: Admin Panel
777:#### Step 7.1: Admin Layout
791:#### Step 7.2: Dashboard
801:#### Step 7.3: Product Management
816:#### Step 7.4: Order Management
828:#### Step 7.5: Other Admin Sections
856:### Phase 8: Search
860:#### Step 8.1: Migrations
866:#### Step 8.2: Search Service
879:#### Step 8.3: Search UI
890:### Phase 9: Analytics
894:#### Step 9.1: Migrations
899:#### Step 9.2: Analytics Service
919:### Phase 10: Apps and Webhooks
923:#### Step 10.1: Migrations
932:#### Step 10.2: Webhook Service
953:### Phase 11: Polish
966:### Phase 12: Full Test Suite Execution
985:### Test Directory Structure
1007:### Unit Tests
1011:#### tests/Unit/PricingEngineTest.php
1034:#### tests/Unit/DiscountCalculatorTest.php
1056:#### tests/Unit/TaxCalculatorTest.php
1072:#### tests/Unit/ShippingCalculatorTest.php
1090:#### tests/Unit/CartVersionTest.php
1104:#### tests/Unit/HandleGeneratorTest.php
1119:### Feature Tests
1130:#### tests/Feature/Tenancy/TenantResolutionTest.php
1143:#### tests/Feature/Tenancy/StoreIsolationTest.php
1155:#### tests/Feature/Auth/AdminAuthTest.php
1172:#### tests/Feature/Auth/CustomerAuthTest.php
1189:#### tests/Feature/Auth/SanctumTokenTest.php
1201:#### tests/Feature/Products/ProductCrudTest.php
1221:#### tests/Feature/Products/VariantTest.php
1236:#### tests/Feature/Products/InventoryTest.php
1251:#### tests/Feature/Products/CollectionTest.php
1265:#### tests/Feature/Products/MediaUploadTest.php
1278:#### tests/Feature/Cart/CartServiceTest.php
1297:#### tests/Feature/Cart/CartApiTest.php
1312:#### tests/Feature/Checkout/CheckoutFlowTest.php
1324:#### tests/Feature/Checkout/CheckoutStateTest.php
1340:#### tests/Feature/Checkout/PricingIntegrationTest.php
1352:#### tests/Feature/Checkout/DiscountTest.php
1365:#### tests/Feature/Checkout/ShippingTest.php
1377:#### tests/Feature/Checkout/TaxTest.php
1388:#### tests/Feature/Orders/OrderCreationTest.php
1404:#### tests/Feature/Orders/RefundTest.php
1418:#### tests/Feature/Orders/FulfillmentTest.php
1437:#### tests/Feature/Payments/MockPaymentProviderTest.php
1450:#### tests/Feature/Payments/PaymentServiceTest.php
1462:#### tests/Feature/Payments/BankTransferConfirmationTest.php
1475:#### tests/Feature/Customers/CustomerAccountTest.php
1488:#### tests/Feature/Customers/AddressManagementTest.php
1502:#### tests/Feature/Admin/DashboardTest.php
1513:#### tests/Feature/Admin/ProductManagementTest.php
1528:#### tests/Feature/Admin/OrderManagementTest.php
1540:#### tests/Feature/Admin/DiscountManagementTest.php
1553:#### tests/Feature/Admin/SettingsTest.php
1566:#### tests/Feature/Search/SearchTest.php
1578:#### tests/Feature/Search/AutocompleteTest.php
1588:#### tests/Feature/Analytics/EventIngestionTest.php
1600:#### tests/Feature/Analytics/AggregationTest.php
1610:#### tests/Feature/Webhooks/WebhookDeliveryTest.php
1622:#### tests/Feature/Webhooks/WebhookSignatureTest.php
1633:#### tests/Feature/Api/StorefrontCartApiTest.php
1648:#### tests/Feature/Api/StorefrontCheckoutApiTest.php
1664:#### tests/Feature/Api/AdminProductApiTest.php
1678:#### tests/Feature/Api/AdminOrderApiTest.php
1693:### Models (with migration, factory, seeder)
1741:### Livewire Components - Admin
1774:### Livewire Components - Storefront
1796:### Middleware
1804:### Policies
1819:### Form Requests
1842:### Jobs
1852:### Events
1867:### Tests
1940:### bootstrap/app.php Changes
1947:### Routes Files
1955:### Service Providers
1963:### Services Directory
| `Admin\Apps\Index` | App directory |
| `Admin\Apps\Show` | Installed app detail |
| `Admin\Developers\Index` | API token management, webhook subscriptions |

---

### Phase 8: Search

**Priority:** LOW - enhances product discovery.

#### Step 8.1: Migrations

1. `create_search_settings_table` - PK: `store_id`
2. `create_search_queries_table` - FK: `store_id`
3. FTS5 virtual table migration (raw SQL) - create `products_fts` virtual table using FTS5 with columns: title, description, vendor, product_type, tags; content synced from `products` table

#### Step 8.2: Search Service

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

| Method | Description |
|--------|-------------|
| `search(Store $store, string $query, array $filters, int $perPage): LengthAwarePaginator` | FTS5 query with store scoping |
| `autocomplete(Store $store, string $prefix, int $limit): Collection` | Prefix matching for search-as-you-type |
| `syncProduct(Product $product): void` | Upsert into FTS5 index |
| `removeProduct(int $productId): void` | Delete from FTS5 index |

**File to create:** `App\Observers\ProductObserver` - calls `SearchService::syncProduct()` on create/update, `removeProduct()` on delete.

#### Step 8.3: Search UI

**Files to create:**

| Component | Description |
|-----------|-------------|
| `Storefront\Search\Modal` | Search-as-you-type modal with autocomplete results |
| `Storefront\Search\Index` | Full search results page with filters (vendor, price range, collection), sort (relevance, price, newest), pagination |

---

### Phase 9: Analytics

**Priority:** LOW - reporting and insights.

#### Step 9.1: Migrations

1. `create_analytics_events_table` - FK: `store_id`, `customer_id`
2. `create_analytics_daily_table` - composite PK: `store_id` + `date`

#### Step 9.2: Analytics Service

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

| Method | Description |
|--------|-------------|
| `track(Store $store, string $type, array $properties, ?string $sessionId, ?int $customerId): void` | Inserts raw event |
| `getDailyMetrics(Store $store, string $startDate, string $endDate): Collection` | Reads pre-aggregated daily data |

Event types: `page_view`, `product_view`, `add_to_cart`, `remove_from_cart`, `checkout_started`, `checkout_completed`, `search`.

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

Requirements:
- Runs daily via `routes/console.php`
- Aggregates raw events into `analytics_daily` rows
- Calculates: orders_count, revenue_amount, aov_amount, visits_count, add_to_cart_count, checkout_started_count

---

### Phase 10: Apps and Webhooks

**Priority:** LOW - extensibility.

#### Step 10.1: Migrations

1. `create_apps_table`
2. `create_app_installations_table` - FK: `store_id`, `app_id`
3. `create_oauth_clients_table` - FK: `app_id`
4. `create_oauth_tokens_table` - FK: `installation_id`
5. `create_webhook_subscriptions_table` - FK: `store_id`, `app_installation_id`
6. `create_webhook_deliveries_table` - FK: `subscription_id`

#### Step 10.2: Webhook Service

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

| Method | Description |
|--------|-------------|
| `dispatch(Store $store, string $eventType, array $payload): void` | Finds matching subscriptions, queues delivery jobs |
| `sign(string $payload, string $secret): string` | HMAC-SHA256 signature |
| `verify(string $payload, string $signature, string $secret): bool` | Verify incoming webhook signatures |

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

Requirements:
- HTTP POST to target URL with JSON payload
- HMAC signature in `X-Platform-Signature` header, event type in `X-Platform-Event`, delivery ID in `X-Platform-Delivery-Id`, timestamp in `X-Platform-Timestamp`
- Retry with exponential backoff: 1 min, 5 min, 30 min, 2 h, 12 h (6 total attempts: 1 initial + 5 retries; Laravel backoff config `[60, 300, 1800, 7200, 43200]`)
- Records response in `webhook_deliveries`
- Circuit breaker: after 5 consecutive failures, pause subscription

---

### Phase 11: Polish

**Priority:** LOW but important for completeness.

1. Accessibility audit: skip links on all pages, ARIA labels on interactive elements, focus management in modals
2. Responsive testing: all pages render correctly at sm/md/lg/xl breakpoints
3. Dark mode: `dark:` prefix on all storefront views and admin views
4. Error pages: styled 404 and 503 pages matching the storefront theme
5. Structured logging: JSON channel in `config/logging.php`
6. Seed data: comprehensive seeders for demo stores

---

### Phase 12: Full Test Suite Execution

**Priority:** Runs after all phases are complete.

**Verification steps:**

1. Run the full unit and feature test suite and confirm all tests pass
2. Run all browser tests and confirm they pass
3. Run the code style formatter/checker and confirm conformance
4. Run a fresh migration with seeding and confirm all migrations and seeders succeed without errors
5. Manual smoke: visit storefront, navigate products, add to cart, checkout
6. Manual smoke: visit admin login, authenticate, manage products and orders

---

## 3. Pest Unit/Feature Test Specification

All tests use Pest syntax. Feature tests use `RefreshDatabase` trait. Unit tests do not hit the database unless factories are involved.

### Test Directory Structure

| Directory | Files |
|-----------|-------|
| `tests/Unit/` | `PricingEngineTest.php`, `DiscountCalculatorTest.php`, `TaxCalculatorTest.php`, `ShippingCalculatorTest.php`, `CartVersionTest.php`, `HandleGeneratorTest.php` |
| `tests/Feature/Tenancy/` | `TenantResolutionTest.php`, `StoreIsolationTest.php` |
| `tests/Feature/Auth/` | `AdminAuthTest.php`, `CustomerAuthTest.php`, `SanctumTokenTest.php` |
| `tests/Feature/Products/` | `ProductCrudTest.php`, `VariantTest.php`, `InventoryTest.php`, `CollectionTest.php`, `MediaUploadTest.php` |
| `tests/Feature/Cart/` | `CartServiceTest.php`, `CartApiTest.php` |
| `tests/Feature/Checkout/` | `CheckoutFlowTest.php`, `CheckoutStateTest.php`, `PricingIntegrationTest.php`, `DiscountTest.php`, `ShippingTest.php`, `TaxTest.php` |
| `tests/Feature/Orders/` | `OrderCreationTest.php`, `RefundTest.php`, `FulfillmentTest.php` |
| `tests/Feature/Payments/` | `MockPaymentProviderTest.php`, `PaymentServiceTest.php`, `BankTransferConfirmationTest.php` |
| `tests/Feature/Customers/` | `CustomerAccountTest.php`, `AddressManagementTest.php` |
| `tests/Feature/Admin/` | `DashboardTest.php`, `ProductManagementTest.php`, `OrderManagementTest.php`, `DiscountManagementTest.php`, `SettingsTest.php` |
| `tests/Feature/Search/` | `SearchTest.php`, `AutocompleteTest.php` |
| `tests/Feature/Analytics/` | `EventIngestionTest.php`, `AggregationTest.php` |
| `tests/Feature/Webhooks/` | `WebhookDeliveryTest.php`, `WebhookSignatureTest.php` |
| `tests/Feature/Api/` | `StorefrontCartApiTest.php`, `StorefrontCheckoutApiTest.php`, `AdminProductApiTest.php`, `AdminOrderApiTest.php` |
| `tests/Browser/` | Covered in `08-PLAYWRIGHT-E2E-PLAN.md` |

---

### Unit Tests

---

#### tests/Unit/PricingEngineTest.php

**Setup:** Instantiate `PricingEngine` directly. Mock dependencies or use simple value objects.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates subtotal from line items | 2 lines: variant A 2499 cents x 2, variant B 7999 cents x 1 | subtotal = 12997 | happy path |
| calculates subtotal for a single line | 1 line: 1500 cents x 3 | subtotal = 4500 | happy path |
| returns zero subtotal for empty cart | 0 lines | subtotal = 0 | edge case |
| applies percent discount correctly | subtotal 10000, discount 10% (value_amount = 10, value_type = percent) | discount = 1000, discounted subtotal = 9000 | happy path |
| applies fixed discount correctly | subtotal 10000, discount 500 cents fixed | discount = 500, discounted subtotal = 9500 | happy path |
| caps fixed discount at subtotal so it never goes negative | subtotal 300, discount 500 cents fixed | discount = 300, discounted subtotal = 0 | edge case |
| applies free shipping discount by zeroing shipping | subtotal 5000, shipping 499, discount type free_shipping | shipping = 0 | happy path |
| calculates tax exclusive correctly | net amount 10000, rate 1900 basis points (19%) | tax = 1900, gross = 11900 | happy path |
| extracts tax from inclusive price correctly | gross amount 11900, rate 1900 basis points (19%) | tax = 1900, net = 10000 | happy path |
| returns zero tax when rate is zero | amount 10000, rate 0 | tax = 0 | edge case |
| calculates shipping flat rate | rate config type=flat, amount=499 | shipping = 499 | happy path |
| calculates full checkout totals end to end | 2 lines x 2499 = 4998, discount WELCOME10 (10%), shipping flat 499, tax 19% inclusive; pipeline: subtotal 4998 -> discount 499 -> discounted 4499 -> shipping 499 -> tax extracted from gross | total calculation matches expected value | happy path |
| handles rounding correctly with odd cent amounts | 3 lines with prices that create fractional discount allocations | sum of rounded allocations equals total discount (no off-by-one) | edge case |
| produces identical results for identical inputs | Run calculation twice with same inputs | both PricingResult objects are identical | edge case |

---

#### tests/Unit/DiscountCalculatorTest.php

**Setup:** Instantiate `DiscountService` with mock store and cart.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| validates an active discount code | discount: status=active, starts_at=yesterday, ends_at=tomorrow | validation passes, returns Discount model | happy path |
| rejects an expired discount code | discount: status=active, ends_at=yesterday | throws InvalidDiscountException with reason 'expired' | validation |
| rejects a not-yet-active discount code | discount: status=active, starts_at=tomorrow | throws InvalidDiscountException with reason 'not_yet_active' | validation |
| rejects a discount that has reached its usage limit | discount: usage_limit=10, usage_count=10 | throws InvalidDiscountException with reason 'usage_limit_reached' | validation |
| rejects an unknown discount code | look up code 'DOESNOTEXIST' | throws InvalidDiscountException with reason 'not_found' | validation |
| performs case-insensitive code lookup | discount with code 'SUMMER20'; validate code 'summer20' | validation passes | edge case |
| enforces minimum purchase amount rule | discount with rules_json containing minimum_purchase: 5000; cart subtotal: 3000 | throws InvalidDiscountException with reason 'minimum_not_met' | validation |
| passes minimum purchase when cart meets threshold | discount with rules_json minimum_purchase: 5000; cart subtotal: 5000 | validation passes | happy path |
| calculates percent discount amount | discount: 15%, subtotal: 10000 | discount amount = 1500 | happy path |
| calculates fixed discount amount | discount: 500 cents fixed, subtotal: 10000 | discount amount = 500 | happy path |
| handles free shipping discount type | discount: type=free_shipping | discount amount = 0, free_shipping flag = true | happy path |
| allocates discount proportionally across multiple lines | 2 lines: line A subtotal 7500, line B subtotal 2500 (total 10000); discount: 10% = 1000 | line A allocation = 750, line B allocation = 250 | happy path |
| distributes rounding remainder to the last qualifying line | 3 lines with subtotals that create uneven division | sum of allocations exactly equals total discount | edge case |

---

#### tests/Unit/TaxCalculatorTest.php

**Setup:** Instantiate `TaxCalculator` directly.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates manual tax exclusive | net amount: 10000, rate: 1900 (19%) | tax = 1900 | happy path |
| extracts manual tax from inclusive amount | gross amount: 11900, rate: 1900 (19%) | tax = 1900, net = 10000 | happy path |
| returns zero tax when no rate is configured | amount: 10000, rate: 0 | tax = 0 | edge case |
| handles zero amount lines | amount: 0, rate: 1900 | tax = 0 | edge case |
| calculates tax with non-standard rate | net amount: 8999, rate: 700 (7%) | tax = 629 (rounded) | happy path |
| extracts tax correctly for small amounts | gross amount: 119, rate: 1900 | tax = 19, net = 100 | edge case |
| handles high tax rates | net amount: 10000, rate: 2500 (25%) | tax = 2500 | happy path |

---

#### tests/Unit/ShippingCalculatorTest.php

**Setup:** Instantiate `ShippingCalculator` with mock zone/rate data.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| matches a zone by country code | zone countries_json: ["DE","AT","CH"]; address country: "DE" | zone matched | happy path |
| matches a zone by region code | zone regions_json: ["US-NY","US-CA"]; address: country "US", province_code "US-NY" | zone matched | happy path |
| returns empty when no zone matches the address | zone countries_json: ["DE"]; address country: "FR" | empty collection returned | edge case |
| calculates a flat rate | rate type: flat, config: amount 499 | shipping = 499 | happy path |
| calculates a weight-based rate | rate type: weight, config: ranges 0-500g=499, 501-2000g=899; cart total weight: 750g | shipping = 899 | happy path |
| calculates a price-based rate | rate type: price, config: ranges 0-5000=799, 5001-999999=399; cart subtotal: 7500 | shipping = 399 | happy path |
| returns zero shipping when no items require shipping | all cart line variants have requires_shipping = false | shipping = 0 | edge case |
| returns the correct rate when multiple zones match and the first is selected | two zones match, each with different rates | returns rates from both zones for customer selection | edge case |
| skips inactive rates | rate with is_active = 0 | rate not included in available rates | edge case |

---

#### tests/Unit/CartVersionTest.php

**Setup:** Instantiate Cart model or CartService.

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| starts at version 1 | create new cart | cart_version = 1 | happy path |
| increments version on add line | add a line to cart | cart_version = 2 | happy path |
| increments version on update quantity | update line quantity | cart_version incremented by 1 | happy path |
| increments version on remove line | remove a line | cart_version incremented by 1 | happy path |
| detects version mismatch | cart at version 3, client sends expected_version 2 | CartVersionMismatchException thrown | validation |

---

#### tests/Unit/HandleGeneratorTest.php

**Setup:** Instantiate `HandleGener…1208 tokens truncated…users to login | GET /admin without auth | redirected to /admin/login | authorization |
| supports remember me functionality | login with remember=true | remember_web cookie is set | happy path |
| records last_login_at on successful login | login successfully | user.last_login_at is updated to now | happy path |

---

#### tests/Feature/Auth/CustomerAuthTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| renders the customer login page | GET /account/login on storefront domain | 200 response, sees login form | happy path |
| authenticates a customer with valid credentials | POST login with valid email/password | redirected to /account | happy path |
| rejects invalid customer credentials | POST login with wrong password | redirected back with errors | validation |
| scopes customer login to the current store | customer exists in store A but not store B; login on store B domain with store A customer credentials | login fails | authorization |
| rate limits customer login attempts | 6 attempts in 1 minute | 429 on 6th attempt | validation |
| registers a new customer | POST /account/register with name, email, password, password_confirmation | customer created in DB, auto-logged in, redirected to /account | happy path |
| rejects duplicate email registration in the same store | customer with email already exists in current store; POST /account/register with same email | validation error on email | validation |
| allows same email in different stores | customer with email in store A; register same email on store B | registration succeeds | edge case |
| logs out customer and redirects to login | login, then POST /account/logout | redirected to /account/login | happy path |
| merges guest cart into customer cart on login | guest has cart with variant A qty 2; customer has cart with variant A qty 1 and variant B qty 3; login | customer cart has variant A qty 3, variant B qty 3; guest cart abandoned | happy path |

---

#### tests/Feature/Auth/SanctumTokenTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a personal access token with abilities | acting as admin, create token with abilities ['read-products', 'write-products'] | token row in personal_access_tokens, token string returned | happy path |
| authenticates API request with valid token | GET /api/admin/v1/stores/{storeId}/products with Bearer token | 200 response | happy path |
| rejects API request with invalid token | GET /api/admin/v1/stores/{storeId}/products with fake token | 401 response | validation |
| enforces token abilities | token has only 'read-products'; POST /api/admin/v1/stores/{storeId}/products (requires write-products) | 403 response | authorization |
| revokes a token | create token, then delete it; make API request with revoked token | 401 response | happy path |

---

#### tests/Feature/Products/ProductCrudTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| lists products for the current store | create 5 products for current store; GET /admin/products | 200 response, sees all 5 product titles | happy path |
| creates a product with a default variant | POST via Livewire action: title, description, status=draft | product in DB, one variant with is_default=1, inventory_item created | happy path |
| generates a unique handle from the title | create product with title "Summer T-Shirt" | handle = "summer-t-shirt" | happy path |
| appends suffix when handle collides | create two products with title "T-Shirt" | handles are "t-shirt" and "t-shirt-1" | edge case |
| updates a product | create product, then update title and description | DB reflects changes | happy path |
| transitions product from draft to active | product in draft with valid variant; transition to active | status = active, published_at set | happy path |
| rejects draft to active without a priced variant | product in draft, variant price = 0; transition to active | exception thrown, status unchanged | validation |
| transitions product from active to archived | active product; transition to archived | status = archived | happy path |
| prevents active to draft when order lines exist | active product with order references; transition to draft | exception thrown | validation |
| hard deletes a draft product with no order references | draft product, no orders; delete | product removed from DB | happy path |
| prevents deletion of product with order references | product with order_lines referencing its variants; attempt delete | exception thrown | validation |
| filters products by status | 3 active, 2 draft, 1 archived; filter by status=active | 3 results | happy path |
| searches products by title | product titled "Organic Cotton Hoodie"; search "cotton" | product found in results | happy path |

---

#### tests/Feature/Products/VariantTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates variants from option matrix | product with options: Size [S, M, L], Color [Red, Blue]; rebuild matrix | 6 variants created (3x2) | happy path |
| preserves existing variants when adding an option value | existing: Size [S, M] = 2 variants; add Size L; rebuild matrix | 3 variants, original 2 unchanged (prices preserved) | happy path |
| archives orphaned variants with order references | variant referenced in order_lines; remove its option value | variant status = archived, not deleted | edge case |
| deletes orphaned variants without order references | variant not referenced in any order; remove its option value | variant deleted from DB | happy path |
| auto-creates default variant for products without options | create product with no options | 1 variant with is_default=1 | happy path |
| validates SKU uniqueness within store | create variant with SKU "TSH-001" in store A; attempt to create another variant with same SKU in store A | validation error | validation |
| allows duplicate SKU across different stores | SKU "TSH-001" in store A; create variant with "TSH-001" in store B | success | edge case |
| allows null SKUs | create two variants with null SKU | both created successfully | edge case |

---

#### tests/Feature/Products/InventoryTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates inventory item when variant is created | create product variant | inventory_item exists with quantity_on_hand=0, quantity_reserved=0 | happy path |
| checks availability correctly | on_hand=10, reserved=3 | available = 7 | happy path |
| reserves inventory | on_hand=10, reserved=0; reserve 3 | reserved=3, available=7 | happy path |
| throws InsufficientInventoryException when reserving more than available with deny policy | on_hand=5, reserved=3, policy=deny; reserve 3 | InsufficientInventoryException thrown | validation |
| allows overselling with continue policy | on_hand=2, reserved=0, policy=continue; reserve 5 | reserved=5 (no exception) | edge case |
| releases reserved inventory | on_hand=10, reserved=5; release 3 | reserved=2 | happy path |
| commits inventory on order completion | on_hand=10, reserved=3; commit 3 | on_hand=7, reserved=0 | happy path |
| restocks inventory | on_hand=5; restock 10 | on_hand=15 | happy path |

---

#### tests/Feature/Products/CollectionTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a collection with a unique handle | create collection titled "Summer Sale" | handle = "summer-sale", saved in DB | happy path |
| adds products to a collection | create collection, add 3 products | collection_products pivot has 3 rows | happy path |
| removes products from a collection | collection with 3 products, remove 1 | 2 remain | happy path |
| reorders products within a collection | 3 products with positions 0,1,2; reorder to 2,0,1 | positions updated correctly | happy path |
| transitions collection from draft to active | draft collection; transition to active | status = active | happy path |
| lists collections with product count | collection A with 5 products, collection B with 3 products; list collections | each collection shows correct product count | happy path |
| scopes collections to current store | store A has 2 collections, store B has 4; current store = A | Collection::count() = 2 | authorization |

---

#### tests/Feature/Products/MediaUploadTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| uploads an image for a product | POST Livewire file upload with a test image | product_media row created with status=processing | happy path |
| processes uploaded image and generates variants | dispatch ProcessMediaUpload job | media status = ready, file exists in storage | happy path |
| rejects non-image file types | attempt to upload a .txt file | validation error | validation |
| sets alt text on media | upload image, then update alt_text | alt_text persisted | happy path |
| reorders media positions | upload 3 images, reorder | position values updated | happy path |
| deletes media and removes file from storage | upload image, then delete | product_media row removed, file removed from disk | happy path |

---

#### tests/Feature/Cart/CartServiceTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a cart for the current store | create cart | cart in DB with store_id, currency, version=1, status=active | happy path |
| adds a line item to the cart | active product with variant priced at 2500; add to cart qty 2 | cart_line created, unit_price=2500, subtotal=5000, total=5000 | happy path |
| increments quantity when adding an existing variant | cart already has variant A qty 1; add variant A qty 2 | single line with qty 3, subtotal=7500 | happy path |
| rejects add when product is not active | product status = draft; attempt to add variant to cart | exception thrown | validation |
| rejects add when inventory is insufficient and policy is deny | variant on_hand=2, policy=deny; attempt to add qty 5 | InsufficientInventoryException | validation |
| allows add when inventory is insufficient but policy is continue | variant on_hand=2, policy=continue; add qty 5 | line created with qty 5 | edge case |
| updates line quantity | cart with variant A qty 2; update to qty 5 | line quantity=5, amounts recalculated | happy path |
| removes a line when quantity set to zero | update line quantity to 0 | line removed from cart | edge case |
| removes a specific line item | cart with 2 lines, remove first | 1 line remains | happy path |
| increments cart version on every mutation | create cart (v1), add line (v2), update qty (v3), remove line (v4) | cart_version = 4 | happy path |
| returns cart via session for guest users | create cart, store ID in session; get cart for session | same cart returned | happy path |
| merges guest cart into customer cart on login | guest cart: variant A qty 2; customer cart: variant A qty 1, variant B qty 3; merge | customer cart has A qty 3, B qty 3; guest cart status=abandoned | happy path |

---

#### tests/Feature/Cart/CartApiTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a cart via API | POST /api/storefront/v1/carts | 201 response, response contains cart data | happy path |
| retrieves a cart via API | create cart, then GET /api/storefront/v1/carts/{id} | 200 response, cart data with lines and totals | happy path |
| adds a line via API | POST /api/storefront/v1/carts/{id}/lines with variant_id and quantity | 200 response, line added to cart | happy path |
| updates line quantity via API | PUT /api/storefront/v1/carts/{id}/lines/{lineId} with quantity | 200 response, quantity updated | happy path |
| removes a line via API | DELETE /api/storefront/v1/carts/{id}/lines/{lineId} | 200 response, line removed | happy path |
| returns 404 for nonexistent cart | GET /api/storefront/v1/carts/nonexistent | 404 response | validation |
| returns 409 on version mismatch | cart at version 3; PUT with expected_version=2 | 409 Conflict with current cart state | validation |
| respects storefront rate limiting | make 121 requests in 1 minute | 121st returns 429 | validation |

---

#### tests/Feature/Checkout/CheckoutFlowTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates a checkout from a cart | cart with 2 lines; create checkout | checkout in DB, status=started, linked to cart | happy path |
| completes full checkout happy path | create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete | order created, cart status=converted, inventory committed | happy path |
| rejects checkout for empty cart | cart with 0 lines; create checkout | validation error | validation |
| expires checkout after timeout | checkout with payment_selected status, expires_at in the past; run ExpireAbandonedCheckouts job | status=expired, inventory released | edge case |
| prevents duplicate orders from same checkout | complete checkout once (creates order); call completeCheckout again | no duplicate order created, same order returned | edge case |

---

#### tests/Feature/Checkout/CheckoutStateTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| transitions from started to addressed with valid address | set email and shipping address | status = addressed | happy path |
| rejects address transition with missing required fields | set address without city | validation error, status remains started | validation |
| transitions from addressed to shipping_selected | set valid shipping rate | status = shipping_selected | happy path |
| rejects shipping selection with rate from wrong zone | address in DE, select rate from US-only zone | exception thrown | validation |
| skips shipping selection when no items require shipping | all variants have requires_shipping=false | transitions directly to shipping_selected with shipping=0 | edge case |
| transitions from shipping_selected to payment_selected | select payment method | status = payment_selected, expires_at set, inventory reserved | happy path |
| transitions from payment_selected to completed | simulate successful payment | status = completed, order created | happy path |
| rejects invalid state transitions | attempt started -> completed | exception thrown | validation |
| recalculates pricing on address change | set address (DE), note tax calculation; change address to different country | pricing recalculated | happy path |

---

#### tests/Feature/Checkout/PricingIntegrationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates correct totals for a simple checkout | 1 item: 2500 x 2 = 5000, no discount, flat shipping 499, 19% tax exclusive | subtotal=5000, shipping=499, tax=1044 (19% of 5499), total=6543 | happy path |
| applies discount code and recalculates | apply 10% discount to 10000 subtotal | discount=1000, discounted subtotal=9000 | happy path |
| stores pricing snapshot in totals_json | complete pricing calculation | checkout.totals_json contains all fields | happy path |
| recalculates on shipping method change | select flat rate 499, then switch to weight-based 899 | totals updated with new shipping amount | happy path |
| handles prices-include-tax correctly | tax settings: prices_include_tax=true, rate=1900; item price 11900 (gross) | tax extracted = 1900, subtotal = 10000 | edge case |

---

#### tests/Feature/Checkout/DiscountTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| applies a valid percent discount code at checkout | discount: 10%, code "SAVE10"; apply to checkout with subtotal 5000 | discount = 500 | happy path |
| applies a valid fixed discount code at checkout | discount: 500 fixed, code "5OFF" | discount = 500 | happy path |
| removes discount when code is cleared | apply discount, then clear code | discount = 0, totals recalculated | happy path |
| rejects expired discount at checkout | discount ended yesterday | error returned | validation |
| increments usage count on order completion | discount usage_count=5; complete checkout with discount | usage_count=6 | happy path |
| handles free shipping discount at checkout | apply free_shipping discount | shipping = 0 in totals | happy path |

---

#### tests/Feature/Checkout/ShippingTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| returns available shipping rates for address | store has zone for DE with flat rate 499; address: country DE | rate returned with name and amount | happy path |
| returns empty when no zone matches address | store has zone for DE only; address: country FR | empty rates | edge case |
| calculates flat rate correctly | select flat rate, amount 499 | shipping in totals = 499 | happy path |
| calculates weight-based rate correctly | cart total weight 750g, rate range 501-2000g = 899 | shipping = 899 | happy path |
| returns zero shipping when all items are digital | all variants: requires_shipping=false | shipping = 0 | edge case |

---

#### tests/Feature/Checkout/TaxTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| calculates exclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: false; discounted subtotal + shipping = 5499 | tax = 1044 (rounded) | happy path |
| extracts inclusive tax correctly at checkout | tax mode: manual, rate: 1900, prices_include_tax: true | tax extracted from gross amounts | happy path |
| applies zero tax when no tax settings exist | no tax_settings row for store | tax = 0 | edge case |
| stores tax lines in totals_json | calculate with rate 1900 | tax_lines array in totals_json has entry with name, rate, amount | happy path |

---

#### tests/Feature/Orders/OrderCreationTest.php

| Test Name | Setup / Preconditions | Key Assertions | Category |
|-----------|----------------------|----------------|----------|
| creates an order from a completed checkout | complete checkout flow | order in DB with correct amounts, status=pending | happy path |
| generates sequential order numbers per store | create 3 orders in store A | order numbers are #1001, #1002, #1003 | happy path |
| creates order lines with snapshots | order from checkout with 2 items | order_lines have title_snapshot, sku_snapshot | happy path |
| commits inventory on order creation | variant on_hand=10, reserved=3; create order with qty 3 | on_hand=7, reserved=0 | happy path |
| marks cart as converted | create order from checkout | cart status = converted | happy path |
| dispatches OrderCreated event | use Event::fake(); create order | OrderCreated dispatched with order | happy path |
| preserves order data when product is deleted | create order, then archive the product | order_lines still accessible with snapshots | edge case |
| links order to customer when authenticated | checkout by logged-in customer | order.customer_id set | happy path |
| sets email from checkout on the order | guest checkout with email "test@example.com" | order.email = "test@example.com" | happy path |


exec

status: completed

input:
const results = await Promise.all([
  tools.exec_command({cmd:"sed -n '1,300p' routes/console.php && find app/Jobs -type f -maxdepth 1 -print -exec sed -n '1,260p' {} \\;", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:40000}),
  tools.exec_command({cmd:"find app/Events app/Listeners app/Observers app/Policies app/Services -maxdepth 2 -type f -print | sort", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:12000}),
  tools.exec_command({cmd:"sed -n '1,360p' app/Services/AnalyticsService.php && sed -n '1,260p' app/Livewire/Admin/Analytics/Index.php && sed -n '1,300p' resources/views/livewire/admin/analytics/index.blade.php", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:30000}),
  tools.exec_command({cmd:"sed -n '1,420p' app/Services/PaymentService.php && sed -n '1,360p' app/Services/OrderService.php", workdir:"/Users/wesner/Herd/shop", yield_time_ms:10000, max_output_tokens:40000})
]);
results.forEach((r,i)=>text(`---${i}---\n${r.output}`));


type: CommandExecution

command: sed -n '1,420p' app/Services/PaymentService.php && sed -n '1,360p' app/Services/OrderService.php

PaymentService.php

type: read

OrderService.php

status: completed

stdout:
<?php

namespace App\Services;

use App\Contracts\PaymentProvider;
use App\Enums\CheckoutStatus;
use App\Enums\PaymentMethod;
use App\Enums\PaymentStatus;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Payment;
use Illuminate\Support\Facades\DB;

class PaymentService
{
    public function __construct(private readonly PaymentProvider $provider, private readonly InventoryService $inventory, private readonly OrderService $orders, private readonly PricingEngine $pricing) {}

    public function pay(Checkout $checkout, PaymentMethod $method, array $details = []): ?Order
    {
        return DB::transaction(function () use ($checkout, $method, $details): ?Order {
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            if ($checkout->status !== CheckoutStatus::PaymentSelected) {
                throw new \LogicException('Checkout must have a selected payment method before payment.');
            }

            $checkout->update(['payment_method' => $method]);
            $this->pricing->calculate($checkout->refresh());
            $checkout->load('cart.lines.variant.inventory');

            $result = $this->provider->charge($checkout, $method, $details);

            if ($result->status === PaymentStatus::Failed) {
                foreach ($checkout->cart->lines as $line) {
                    if ($line->variant->inventory !== null) {
                        $this->inventory->release($line->variant->inventory, $line->quantity);
                    }
                }

                $checkout->update(['status' => CheckoutStatus::ShippingSelected]);

                return null;
            }

            $order = $this->orders->createFromCheckout($checkout, $result);
            Payment::create(['order_id' => $order->getKey(), 'provider' => 'mock', 'provider_payment_id' => $result->reference, 'method' => $method, 'status' => $result->status, 'amount' => $order->total_amount, 'currency' => $order->currency, 'raw_json_encrypted' => json_encode(['reference' => $result->reference, 'message' => $result->message])]);

            if ($checkout->discount_code !== null) {
                Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->increment('usage_count');
            }

            return $order->refresh()->load(['lines', 'payments']);
        });
    }
}
<?php

namespace App\Services;

use App\Enums\FinancialStatus;
use App\Enums\FulfillmentStatus;
use App\Enums\OrderStatus;
use App\Enums\PaymentStatus;
use App\Events\OrderCancelled;
use App\Events\OrderCreated;
use App\Events\OrderPaid;
use App\Models\Checkout;
use App\Models\Discount;
use App\Models\Order;
use App\Models\Store;
use App\ValueObjects\PaymentResult;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;

class OrderService
{
    public function __construct(private readonly InventoryService $inventory, private readonly AuditLogger $audit) {}

    public function createFromCheckout(Checkout $checkout, ?PaymentResult $paymentResult = null): Order
    {
        return DB::transaction(function () use ($checkout, $paymentResult): Order {
            $checkout->load(['cart.lines.variant.product', 'cart.lines.variant.inventory', 'shippingRate']);
            $existing = Order::withoutGlobalScopes()->where('checkout_id', $checkout->getKey())->first();

            if ($existing !== null) {
                return $existing->load(['lines', 'payments']);
            }

            $totals = $checkout->totals_json ?? ['subtotal' => 0, 'discount' => 0, 'shipping' => 0, 'tax' => 0, 'total' => 0, 'currency' => $checkout->cart->currency];
            $discount = $checkout->discount_code === null ? null : Discount::withoutGlobalScopes()->where('store_id', $checkout->store_id)->whereRaw('lower(code) = ?', [strtolower($checkout->discount_code)])->first();
            $status = $paymentResult?->status === PaymentStatus::Captured ? FinancialStatus::Paid : FinancialStatus::Pending;
            $taxByLine = $this->allocateTaxLines($checkout->cart->lines, $totals['tax_lines'] ?? []);
            $order = Order::withoutGlobalScopes()->create([
                'store_id' => $checkout->store_id,
                'customer_id' => $checkout->customer_id,
                'checkout_id' => $checkout->getKey(),
                'order_number' => $this->generateOrderNumber(Store::findOrFail($checkout->store_id)),
                'currency' => $totals['currency'],
                'status' => $status === FinancialStatus::Paid ? OrderStatus::Paid : OrderStatus::Pending,
                'financial_status' => $status,
                'fulfillment_status' => 'unfulfilled',
                'payment_method' => $checkout->payment_method,
                'email' => $checkout->email,
                'shipping_address_json' => $checkout->shipping_address_json,
                'billing_address_json' => $checkout->billing_address_json,
                'subtotal_amount' => $totals['subtotal'],
                'discount_amount' => $totals['discount'] ?? 0,
                'shipping_amount' => $totals['shipping'] ?? 0,
                'tax_amount' => $totals['tax'] ?? 0,
                'total_amount' => $totals['total'],
                'placed_at' => now(),
            ]);

            foreach ($checkout->cart->lines as $line) {
                $order->lines()->create([
                    'product_id' => $line->variant->product_id,
                    'variant_id' => $line->variant_id,
                    'product_title' => $line->variant->product->title,
                    'title_snapshot' => $line->variant->product->title.' · '.$line->variant->title,
                    'variant_title' => $line->variant->title,
                    'sku' => $line->variant->sku,
                    'sku_snapshot' => $line->variant->sku,
                    'quantity' => $line->quantity,
                    'unit_price_amount' => $line->unit_price_amount,
                    'line_subtotal_amount' => $line->line_subtotal_amount,
                    'line_discount_amount' => $line->line_discount_amount,
                    'line_total_amount' => $line->line_total_amount,
                    'tax_lines_json' => $taxByLine[$line->getKey()] ?? [],
                    'discount_allocations_json' => $discount === null || $line->line_discount_amount < 1 ? [] : [['discount_id' => $discount->getKey(), 'amount' => $line->line_discount_amount]],
                ]);

                if ($paymentResult?->status === PaymentStatus::Captured && $line->variant->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => 'completed']);
            $checkout->cart->update(['status' => 'converted']);
            OrderCreated::dispatch($order);
            $this->audit->record('order.created', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);
            $order->load(['lines', 'payments']);

            if ($status === FinancialStatus::Paid) {
                OrderPaid::dispatch($order);
                $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

                if ($checkout->cart->lines->every(fn ($line): bool => ! $line->variant->requires_shipping)) {
                    $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);

                    foreach ($order->lines as $line) {
                        $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
                    }

                    $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
                }
            }

            return $order;
        });
    }

    public function generateOrderNumber(Store $store): string
    {
        $lastNumber = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->selectRaw("max(cast(replace(order_number, '#', '') as integer)) as value")->value('value');

        return (string) config('shop.order_prefix', '#').(max(1000, (int) $lastNumber) + 1);
    }

    public function cancel(Order $order, string $reason): void
    {
        if ($order->fulfillment_status !== FulfillmentStatus::Unfulfilled) {
            throw new \LogicException('Fulfilled orders cannot be cancelled.');
        }

        DB::transaction(function () use ($order, $reason): void {
            $wasPending = $order->financial_status === FinancialStatus::Pending;
            $order->load('lines.variant.inventory')->update(['status' => OrderStatus::Cancelled, 'financial_status' => $order->financial_status === FinancialStatus::Pending ? FinancialStatus::Voided : $order->financial_status, 'metadata' => array_merge($order->metadata ?? [], ['cancellation_reason' => $reason])]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null && $wasPending) {
                    $this->inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->whereIn('status', [PaymentStatus::Pending, PaymentStatus::Authorized])->update(['status' => PaymentStatus::Failed]);

            OrderCancelled::dispatch($order->refresh());
            $this->audit->record('order.cancelled', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number, 'reason' => $reason]);
        });
    }

    public function confirmPayment(Order $order): void
    {
        if ($order->payment_method !== 'bank_transfer' || $order->financial_status !== FinancialStatus::Pending) {
            throw new \LogicException('Only pending bank transfer orders can be confirmed.');
        }

        DB::transaction(function () use ($order): void {
            $order->load('lines.variant.inventory')->update(['financial_status' => FinancialStatus::Paid, 'status' => OrderStatus::Paid]);

            foreach ($order->lines as $line) {
                if ($line->variant?->inventory !== null) {
                    $this->inventory->commit($line->variant->inventory, $line->quantity);
                }
            }

            $order->payments()->where('status', PaymentStatus::Pending)->update(['status' => PaymentStatus::Captured]);
            OrderPaid::dispatch($order->refresh());
            $this->audit->record('order.paid', $order, ['store_id' => $order->store_id, 'order_number' => $order->order_number]);

            if ($order->lines->every(fn ($line): bool => ! $line->variant?->requires_shipping)) {
                $this->autoFulfillDigitalOrder($order->refresh());
            }
        });
    }

    private function autoFulfillDigitalOrder(Order $order): void
    {
        if ($order->fulfillments()->exists()) {
            return;
        }

        $fulfillment = $order->fulfillments()->create(['status' => 'delivered', 'fulfilled_at' => now(), 'delivered_at' => now()]);
        foreach ($order->lines as $line) {
            $fulfillment->lines()->create(['order_line_id' => $line->getKey(), 'quantity' => $line->quantity]);
        }
        $order->update(['status' => OrderStatus::Fulfilled, 'fulfillment_status' => FulfillmentStatus::Fulfilled]);
    }

    /**
     * @param  \Illuminate\Support\Collection<int, \App\Models\CartLine>  $lines
     * @param  array<int, array{title?: string, rate?: int, amount?: int}>  $taxLines
     * @return array<int, array<int, array<string, mixed>>>
     */
    private function allocateTaxLines(Collection $lines, array $taxLines): array
    {
        $base = max(1, (int) $lines->sum('line_total_amount'));
        $allocations = $lines->mapWithKeys(fn ($line): array => [$line->getKey() => []])->all();

        foreach ($taxLines as $taxLine) {
            $remaining = (int) ($taxLine['amount'] ?? 0);
            $lineCount = $lines->count();

            foreach ($lines->values() as $index => $line) {
                $amount = $index === $lineCount - 1
                    ? $remaining
                    : intdiv((int) ($taxLine['amount'] ?? 0) * (int) $line->line_total_amount, $base);
                $remaining -= $amount;

                if ($amount > 0) {
                    $allocations[$line->getKey()][] = [...$taxLine, 'amount' => $amount];
                }
            }
        }

        return $allocations;
    }
}


command: sed -n '1,360p' app/Services/AnalyticsService.php && sed -n '1,260p' app/Livewire/Admin/Analytics/Index.php && sed -n '1,300p' resources/views/livewire/admin/analytics/index.blade.php

index.blade.php

stdout:
<?php

namespace App\Services;

use App\Models\AnalyticsEvent;
use App\Models\Store;
use Illuminate\Support\Collection;

class AnalyticsService
{
    /** @var array<int, string> */
    private array $eventTypes = ['page_view', 'product_view', 'add_to_cart', 'remove_from_cart', 'checkout_started', 'checkout_completed', 'search'];

    public function track(Store $store, string $type, array $properties = [], ?string $sessionId = null, ?int $customerId = null, ?string $clientEventId = null, ?\DateTimeInterface $occurredAt = null): AnalyticsEvent
    {
        if (! in_array($type, $this->eventTypes, true)) {
            throw new \InvalidArgumentException('Unsupported analytics event type.');
        }

        if ($clientEventId !== null) {
            $existing = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->where('client_event_id', $clientEventId)->first();

            if ($existing !== null) {
                return $existing;
            }
        }

        return AnalyticsEvent::withoutGlobalScopes()->create(['store_id' => $store->getKey(), 'type' => $type, 'session_id' => $sessionId, 'customer_id' => $customerId, 'client_event_id' => $clientEventId, 'payload' => $properties, 'properties_json' => $properties, 'occurred_at' => $occurredAt ?? now()]);
    }

    public function getDailyMetrics(Store $store, string $startDate, string $endDate): Collection
    {
        return \App\Models\AnalyticsDaily::withoutGlobalScopes()->where('store_id', $store->getKey())->whereBetween('date', [$startDate, $endDate])->orderBy('date')->get();
    }
}
<?php

namespace App\Livewire\Admin\Analytics;

use App\Models\AnalyticsDaily;
use Illuminate\Contracts\View\View;
use Livewire\Component;

class Index extends Component
{
    public string $range = '30';

    public function render(): View
    {
        $days = AnalyticsDaily::query()->where('date', '>=', now()->subDays((int) $this->range - 1)->toDateString())->orderBy('date')->get();
        $summary = [
            'visits' => (int) $days->sum('visits_count'),
            'orders' => (int) $days->sum('orders_count'),
            'revenue' => (int) $days->sum('revenue_amount'),
            'add_to_cart' => (int) $days->sum('add_to_cart_count'),
            'checkout_started' => (int) $days->sum('checkout_started_count'),
            'checkout_completed' => (int) $days->sum('checkout_completed_count'),
        ];

        return view('livewire.admin.analytics.index', compact('days', 'summary'))->layout('layouts.admin');
    }
}

Insights

Analytics

Monitor traffic, conversion, orders, and revenue.

<flux:select wire:model.live="range" aria-label="Analytics date range"><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option></flux:select>

@foreach([['Visitors', $summary['visits']], ['Orders', $summary['orders']], ['Revenue', '€'.number_format($summary['revenue'] / 100, 2)], ['Conversion', $summary['visits'] > 0 ? number_format($summary['orders'] / $summary['visits'] * 100, 2).'%' : '0%']] as [$label, $value])

{{ $label }}

{{ $value }}

@endforeach

Conversion funnel

Visits

{{ number_format($summary['visits']) }}

Add to cart

{{ number_format($summary['add_to_cart']) }}

Checkout started

{{ number_format($summary['checkout_started']) }}

Checkout completed

{{ number_format($summary['checkout_completed']) }}

Daily performance

Date	Visits	Orders	Revenue

@forelse($days->reverse() as $day)
{{ $day->date->toDateString() }}	{{ $day->visits_count }}	{{ $day->orders_count }}	€{{ number_format($day->revenue_amount / 100, 2) }}
@empty
No analytics data for this period.
@endforelse


command: sed -n '1,300p' routes/console.php && find app/Jobs -type f -maxdepth 1 -print -exec sed -n '1,260p' {} \;

console.php

cmd: sed -n '1,300p' routes/console.php

cmd: find app/Jobs -type f -maxdepth 1 -print -exec sed -n '1,260p' '{}' "\\;"

path: Jobs

stdout:
<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new \App\Jobs\CleanupAbandonedCarts)->daily();
Schedule::job(new \App\Jobs\ExpireAbandonedCheckouts)->everyFifteenMinutes();
Schedule::job(new \App\Jobs\AggregateAnalytics)->dailyAt('01:00');
Schedule::job(new \App\Jobs\CancelUnpaidBankTransferOrders)->dailyAt('02:00');

Artisan::command('inspire', function () {
    $this->comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
app/Jobs/ExpireAbandonedCheckouts.php
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class ExpireAbandonedCheckouts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Checkout::withoutGlobalScopes()->whereNotIn('status', [CheckoutStatus::Completed, CheckoutStatus::Expired])->where('expires_at', '<', now())->with('cart.lines.variant.inventory')->each(function (Checkout $checkout) use ($inventory): void {
            foreach ($checkout->cart->lines as $line) {
                if ($line->variant->inventory !== null && $checkout->status === CheckoutStatus::PaymentSelected) {
                    $inventory->release($line->variant->inventory, $line->quantity);
                }
            }

            $checkout->update(['status' => CheckoutStatus::Expired]);
        });
    }
}
app/Jobs/CleanupAbandonedCarts.php
<?php

namespace App\Jobs;

use App\Enums\CheckoutStatus;
use App\Models\Cart;
use App\Models\Checkout;
use App\Services\InventoryService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\DB;

class CleanupAbandonedCarts implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(InventoryService $inventory): void
    {
        Cart::withoutGlobalScopes()->where('status', 'active')->where('updated_at', '<', now()->subDays(14))->each(function (Cart $cart) use ($inventory): void {
            DB::transaction(function () use ($cart, $inventory): void {
                $cart->load('lines.variant.inventory');
                $checkouts = Checkout::withoutGlobalScopes()
                    ->where('cart_id', $cart->getKey())
                    ->where('status', CheckoutStatus::PaymentSelected->value)
                    ->lockForUpdate()
                    ->get();

                foreach ($checkouts as $checkout) {
                    foreach ($cart->lines as $line) {
                        if ($line->variant?->inventory !== null) {
                            $inventory->release($line->variant->inventory, $line->quantity);
                        }
                    }

                    $checkout->update(['status' => CheckoutStatus::Expired]);
                }

                $cart->update(['status' => 'abandoned']);
            });
        });
    }
}
app/Jobs/AggregateAnalytics.php
<?php

namespace App\Jobs;

use App\Models\AnalyticsDaily;
use App\Models\AnalyticsEvent;
use App\Models\Order;
use App\Models\Store;
use Carbon\CarbonImmutable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class AggregateAnalytics implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function __construct(public ?Store $store = null, public ?string $date = null) {}

    public function handle(): void
    {
        $date = CarbonImmutable::parse($this->date ?? now()->subDay()->toDateString());
        $stores = $this->store === null ? Store::query()->get() : collect([$this->store]);

        foreach ($stores as $store) {
            $events = AnalyticsEvent::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('created_at', $date)->get();
            $orders = Order::withoutGlobalScopes()->where('store_id', $store->getKey())->whereDate('placed_at', $date)->whereIn('financial_status', ['paid', 'partially_refunded'])->get();
            $revenue = (int) $orders->sum('total_amount');

            AnalyticsDaily::withoutGlobalScopes()->newQuery()->updateOrInsert(
                ['store_id' => $store->getKey(), 'date' => $date->toDateString()],
                [
                    'orders_count' => $orders->count(),
                    'revenue_amount' => $revenue,
                    'aov_amount' => $orders->count() > 0 ? intdiv($revenue, $orders->count()) : 0,
                    'visits_count' => $events->where('type', 'page_view')->count(),
                    'add_to_cart_count' => $events->where('type', 'add_to_cart')->count(),
                    'checkout_started_count' => $events->where('type', 'checkout_started')->count(),
                    'checkout_completed_count' => $events->where('type', 'checkout_completed')->count(),
                ],
            );
        }
    }
}
app/Jobs/CancelUnpaidBankTransferOrders.php
<?php

namespace App\Jobs;

use App\Enums\FinancialStatus;
use App\Models\Order;
use App\Services\OrderService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class CancelUnpaidBankTransferOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public function handle(OrderService $orders): void
    {
        $days = (int) config('shop.bank_transfer_expiry_days', 7);
        Order::withoutGlobalScopes()->where('payment_method', 'bank_transfer')->where('financial_status', FinancialStatus::Pending)->where('placed_at', '<', now()->subDays($days))->with('lines.variant.inventory')->each(fn (Order $order): mixed => $orders->cancel($order, 'Bank transfer payment expired.'));
    }
}
app/Jobs/ProcessMediaUpload.php
<?php

namespace App\Jobs;

use App\Models\ProductMedia;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use RuntimeException;
use Throwable;

class ProcessMediaUpload implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public int $tries = 3;

    /** @var array<int, int> */
    public array $backoff = [10, 30, 60];

    public function __construct(public ProductMedia $media) {}

    public function handle(): void
    {
        $media = $this->media->fresh();
        $disk = Storage::disk('public');
        $sourceKey = $media?->storage_key ?: $media?->path;

        if ($media === null || $sourceKey === null || ! $disk->exists($sourceKey)) {
            throw new RuntimeException('The uploaded media file is not available yet.');
        }

        $contents = $disk->get($sourceKey);
        $mimeType = $media->mime_type ?: $disk->mimeType($sourceKey) ?: 'application/octet-stream';
        $metadata = $media->metadata ?? [];
        $width = null;
        $height = null;
        $variants = ['original' => $sourceKey];

        if (str_starts_with($mimeType, 'image/')) {
            $dimensions = @getimagesizefromstring($contents);
            if ($dimensions === false) {
                throw new RuntimeException('The uploaded image could not be decoded.');
            }
            [$width, $height] = $dimensions;
            $variants = $this->writeImageVariants($disk, $sourceKey, $contents, $mimeType, $width, $height);
        }

        $metadata['variants'] = $variants;
        $metadata['processed_at'] = now()->toIso8601String();
        $media->update([
            'status' => 'ready',
            'width' => $width,
            'height' => $height,
            'mime_type' => $mimeType,
            'byte_size' => strlen($contents),
            'checksum' => hash('sha256', $contents),
            'url' => $disk->url($sourceKey),
            'metadata' => $metadata,
        ]);
    }

    public function failed(Throwable $exception): void
    {
        $media = $this->media->fresh();
        $metadata = $media?->metadata ?? [];
        $metadata['error'] = $exception->getMessage();
        $metadata['failed_at'] = now()->toIso8601String();
        $media?->update(['status' => 'failed', 'metadata' => $metadata]);
    }

    /**
     * @return array<string, string>
     */
    private function writeImageVariants(object $disk, string $sourceKey, string $contents, string $mimeType, int $width, int $height): array
    {
        $variants = ['original' => $sourceKey];
        $directory = trim(pathinfo($sourceKey, PATHINFO_DIRNAME), '.');
        $extension = strtolower(pathinfo($sourceKey, PATHINFO_EXTENSION));

        foreach (['thumbnail' => 320, 'medium' => 800, 'large' => 1600] as $name => $maximum) {
            if ($width <= $maximum && $height <= $maximum) {
                $variants[$name] = $sourceKey;

                continue;
            }

            $resized = $this->resize($contents, $mimeType, $width, $height, $maximum);
            if ($resized === null) {
                $variants[$name] = $sourceKey;

                continue;
            }

            $key = $directory.'/'.$name.'.'.$extension;
            $disk->put($key, $resized);
            $variants[$name] = $key;
        }

        return $variants;
    }

    private function resize(string $contents, string $mimeType, int $width, int $height, int $maximum): ?string
    {
        $source = @imagecreatefromstring($contents);
        if ($source === false) {
            return null;
        }

        $scale = min($maximum / $width, $maximum / $height);
        $targetWidth = max(1, (int) round($width * $scale));
        $targetHeight = max(1, (int) round($height * $scale));
        $target = imagecreatetruecolor($targetWidth, $targetHeight);
        imagealphablending($target, false);
        imagesavealpha($target, true);
        imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
        ob_start();

        $written = match ($mimeType) {
            'image/png' => imagepng($target, null, 8),
            'image/webp' => function_exists('imagewebp') ? imagewebp($target, null, 85) : false,
            default => imagejpeg($target, null, 85),
        };

        $result = $written ? ob_get_clean() : false;
        imagedestroy($source);
        imagedestroy($target);

        return is_string($result) ? $result : null;
    }
}
app/Jobs/DeliverWebhook.php
<?php

namespace App\Jobs;

use App\Models\WebhookDelivery;
use App\Services\WebhookService;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Throwable;

class DeliverWebhook implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, SerializesModels;

    public int $tries = 6;

    public function __construct(public WebhookDelivery $delivery) {}

    /** @return array<int, int> */
    public function backoff(): array
    {
        return [60, 300, 1800, 7200, 43200];
    }

    public function handle(WebhookService $webhooks): void
    {
        $this->delivery->load('subscription');
        $payload = json_encode($this->delivery->payload, JSON_THROW_ON_ERROR);
        $subscription = $this->delivery->subscription;
        $timestamp = (string) now()->timestamp;
        $response = Http::withHeaders([
            'Content-Type' => 'application/json',
            'X-Platform-Signature' => $webhooks->sign($timestamp.'.'.$payload, $subscription->signing_secret_encrypted),
            'X-Platform-Event' => $this->delivery->event,
            'X-Platform-Delivery-Id' => (string) $this->delivery->getKey(),
            'X-Platform-Timestamp' => $timestamp,
        ])->timeout(10)->post($subscription->target_url, $this->delivery->payload);

        $this->delivery->increment('attempts');
        $this->delivery->increment('attempt_count');
        $this->delivery->update([
            'response_status' => $response->status(),
            'response_code' => $response->status(),
            'response_body' => mb_substr($response->body(), 0, 10000),
            'response_body_snippet' => mb_substr($response->body(), 0, 1000),
            'last_attempt_at' => now(),
        ]);

        if ($response->successful()) {
            $subscription->update(['consecutive_failures' => 0]);
            $this->delivery->update(['status' => 'delivered', 'delivered_at' => now(), 'next_attempt_at' => null]);

            return;
        }

        $subscription->increment('consecutive_failures');

        if ($subscription->fresh()->consecutive_failures >= 5) {
            $subscription->update(['status' => 'paused']);
        }

        throw new \RuntimeException('Webhook delivery failed with HTTP '.$response->status());
    }

    public function failed(?Throwable $exception): void
    {
        $this->delivery->update(['status' => 'failed', 'next_attempt_at' => null]);
    }
}


command: find app/Events app/Listeners app/Observers app/Policies app/Services -maxdepth 2 -type f -print | sort

path: Events

stdout:
find: app/Listeners: No such file or directory
app/Events/FulfillmentDelivered.php
app/Events/FulfillmentShipped.php
app/Events/OrderCancelled.php
app/Events/OrderCreated.php
app/Events/OrderFulfilled.php
app/Events/OrderPaid.php
app/Events/OrderRefunded.php
app/Events/ProductStatusChanged.php
app/Observers/ProductObserver.php
app/Policies/CollectionPolicy.php
app/Policies/CustomerPolicy.php
app/Policies/DiscountPolicy.php
app/Policies/FulfillmentPolicy.php
app/Policies/OrderPolicy.php
app/Policies/PagePolicy.php
app/Policies/ProductPolicy.php
app/Policies/RefundPolicy.php
app/Policies/StorePolicy.php
app/Policies/ThemePolicy.php
app/Services/AnalyticsService.php
app/Services/AuditLogger.php
app/Services/CartService.php
app/Services/CheckoutService.php
app/Services/DiscountService.php
app/Services/FulfillmentService.php
app/Services/InventoryService.php
app/Services/MockPaymentProvider.php
app/Services/OrderService.php
app/Services/PaymentProvider.php
app/Services/PaymentService.php
app/Services/PricingEngine.php
app/Services/ProductService.php
app/Services/RefundService.php
app/Services/SearchService.php
app/Services/ShippingCalculator.php
app/Services/Tax/ManualTaxProvider.php
app/Services/Tax/StripeTaxProvider.php
app/Services/TaxCalculator.php
app/Services/VariantMatrixService.php
app/Services/WebhookService.php