From 9e2ab6d7bcf958d04a4fc6eedfdfc4d2b86747b6 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 10:01:33 +0200
Subject: [PATCH 01/29] Init
---
.../skills/developing-with-fortify/SKILL.md | 116 +++++
.agents/skills/fluxui-development/SKILL.md | 81 ++++
.../skills/laravel-best-practices/SKILL.md | 59 +++
.../rules/advanced-queries.md | 106 +++++
.../rules/architecture.md | 202 ++++++++
.../rules/blade-views.md | 36 ++
.../laravel-best-practices/rules/caching.md | 70 +++
.../rules/collections.md | 44 ++
.../laravel-best-practices/rules/config.md | 73 +++
.../rules/db-performance.md | 192 ++++++++
.../laravel-best-practices/rules/eloquent.md | 148 ++++++
.../rules/error-handling.md | 72 +++
.../rules/events-notifications.md | 52 +++
.../rules/http-client.md | 160 +++++++
.../laravel-best-practices/rules/mail.md | 27 ++
.../rules/migrations.md | 121 +++++
.../rules/queue-jobs.md | 144 ++++++
.../laravel-best-practices/rules/routing.md | 99 ++++
.../rules/scheduling.md | 39 ++
.../laravel-best-practices/rules/security.md | 198 ++++++++
.../laravel-best-practices/rules/style.md | 125 +++++
.../laravel-best-practices/rules/testing.md | 43 ++
.../rules/validation.md | 75 +++
.agents/skills/livewire-development/SKILL.md | 175 +++++++
.../reference/javascript-hooks.md | 39 ++
.agents/skills/pest-testing/SKILL.md | 166 +++++++
.../skills/tailwindcss-development/SKILL.md | 119 +++++
.../skills/developing-with-fortify/SKILL.md | 116 +++++
.claude/skills/fluxui-development/SKILL.md | 81 ++++
.../skills/laravel-best-practices/SKILL.md | 59 +++
.../rules/advanced-queries.md | 106 +++++
.../rules/architecture.md | 202 ++++++++
.../rules/blade-views.md | 36 ++
.../laravel-best-practices/rules/caching.md | 70 +++
.../rules/collections.md | 44 ++
.../laravel-best-practices/rules/config.md | 73 +++
.../rules/db-performance.md | 192 ++++++++
.../laravel-best-practices/rules/eloquent.md | 148 ++++++
.../rules/error-handling.md | 72 +++
.../rules/events-notifications.md | 52 +++
.../rules/http-client.md | 160 +++++++
.../laravel-best-practices/rules/mail.md | 27 ++
.../rules/migrations.md | 121 +++++
.../rules/queue-jobs.md | 144 ++++++
.../laravel-best-practices/rules/routing.md | 99 ++++
.../rules/scheduling.md | 39 ++
.../laravel-best-practices/rules/security.md | 198 ++++++++
.../laravel-best-practices/rules/style.md | 125 +++++
.../laravel-best-practices/rules/testing.md | 43 ++
.../rules/validation.md | 75 +++
.claude/skills/livewire-development/SKILL.md | 175 +++++++
.../reference/javascript-hooks.md | 39 ++
.claude/skills/pest-testing/SKILL.md | 166 +++++++
.../skills/tailwindcss-development/SKILL.md | 119 +++++
.codex/config.toml | 3 +
.mcp.json | 8 +-
.../console-2026-07-18T09-15-35-246Z.log | 1 +
.../console-2026-07-18T11-28-30-241Z.log | 3 +
.../console-2026-07-18T11-28-44-297Z.log | 1 +
.../console-2026-07-18T11-28-53-067Z.log | 3 +
.../console-2026-07-18T11-29-27-652Z.log | 18 +
.../console-2026-07-18T11-30-44-355Z.log | 3 +
.../console-2026-07-18T11-31-07-313Z.log | 2 +
.../page-2026-07-18T09-15-35-387Z.yml | 26 ++
.../page-2026-07-18T11-28-30-586Z.yml | 159 +++++++
.../page-2026-07-18T11-28-40-022Z.yml | 147 ++++++
.../page-2026-07-18T11-28-44-401Z.yml | 107 +++++
.../page-2026-07-18T11-28-51-361Z.yml | 144 ++++++
.../page-2026-07-18T11-28-53-172Z.yml | 112 +++++
.../page-2026-07-18T11-28-59-558Z.yml | 159 +++++++
.../page-2026-07-18T11-29-27-744Z.yml | 23 +
.../page-2026-07-18T11-30-44-478Z.yml | 108 +++++
.../page-2026-07-18T11-31-07-430Z.yml | 108 +++++
.../page-2026-07-18T13-20-19-774Z.yml | 159 +++++++
.../page-2026-07-18T13-20-42-149Z.yml | 197 ++++++++
AGENTS.md | 217 +++++++++
CLAUDE.md | 436 +++++-------------
README.md | 7 +
boost.json | 19 +
composer.json | 2 +-
composer.lock | 118 ++---
opencode.json | 14 +
82 files changed, 7483 insertions(+), 383 deletions(-)
create mode 100644 .agents/skills/developing-with-fortify/SKILL.md
create mode 100644 .agents/skills/fluxui-development/SKILL.md
create mode 100644 .agents/skills/laravel-best-practices/SKILL.md
create mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md
create mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md
create mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md
create mode 100644 .agents/skills/laravel-best-practices/rules/caching.md
create mode 100644 .agents/skills/laravel-best-practices/rules/collections.md
create mode 100644 .agents/skills/laravel-best-practices/rules/config.md
create mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md
create mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md
create mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md
create mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md
create mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md
create mode 100644 .agents/skills/laravel-best-practices/rules/mail.md
create mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md
create mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md
create mode 100644 .agents/skills/laravel-best-practices/rules/routing.md
create mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md
create mode 100644 .agents/skills/laravel-best-practices/rules/security.md
create mode 100644 .agents/skills/laravel-best-practices/rules/style.md
create mode 100644 .agents/skills/laravel-best-practices/rules/testing.md
create mode 100644 .agents/skills/laravel-best-practices/rules/validation.md
create mode 100644 .agents/skills/livewire-development/SKILL.md
create mode 100644 .agents/skills/livewire-development/reference/javascript-hooks.md
create mode 100644 .agents/skills/pest-testing/SKILL.md
create mode 100644 .agents/skills/tailwindcss-development/SKILL.md
create mode 100644 .claude/skills/developing-with-fortify/SKILL.md
create mode 100644 .claude/skills/fluxui-development/SKILL.md
create mode 100644 .claude/skills/laravel-best-practices/SKILL.md
create mode 100644 .claude/skills/laravel-best-practices/rules/advanced-queries.md
create mode 100644 .claude/skills/laravel-best-practices/rules/architecture.md
create mode 100644 .claude/skills/laravel-best-practices/rules/blade-views.md
create mode 100644 .claude/skills/laravel-best-practices/rules/caching.md
create mode 100644 .claude/skills/laravel-best-practices/rules/collections.md
create mode 100644 .claude/skills/laravel-best-practices/rules/config.md
create mode 100644 .claude/skills/laravel-best-practices/rules/db-performance.md
create mode 100644 .claude/skills/laravel-best-practices/rules/eloquent.md
create mode 100644 .claude/skills/laravel-best-practices/rules/error-handling.md
create mode 100644 .claude/skills/laravel-best-practices/rules/events-notifications.md
create mode 100644 .claude/skills/laravel-best-practices/rules/http-client.md
create mode 100644 .claude/skills/laravel-best-practices/rules/mail.md
create mode 100644 .claude/skills/laravel-best-practices/rules/migrations.md
create mode 100644 .claude/skills/laravel-best-practices/rules/queue-jobs.md
create mode 100644 .claude/skills/laravel-best-practices/rules/routing.md
create mode 100644 .claude/skills/laravel-best-practices/rules/scheduling.md
create mode 100644 .claude/skills/laravel-best-practices/rules/security.md
create mode 100644 .claude/skills/laravel-best-practices/rules/style.md
create mode 100644 .claude/skills/laravel-best-practices/rules/testing.md
create mode 100644 .claude/skills/laravel-best-practices/rules/validation.md
create mode 100644 .claude/skills/livewire-development/SKILL.md
create mode 100644 .claude/skills/livewire-development/reference/javascript-hooks.md
create mode 100644 .claude/skills/pest-testing/SKILL.md
create mode 100644 .claude/skills/tailwindcss-development/SKILL.md
create mode 100644 .codex/config.toml
create mode 100644 .playwright-mcp/console-2026-07-18T09-15-35-246Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-28-30-241Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-28-44-297Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-28-53-067Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-29-27-652Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-30-44-355Z.log
create mode 100644 .playwright-mcp/console-2026-07-18T11-31-07-313Z.log
create mode 100644 .playwright-mcp/page-2026-07-18T09-15-35-387Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-30-586Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-40-022Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-44-401Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-51-361Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-53-172Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-28-59-558Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-29-27-744Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-30-44-478Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T11-31-07-430Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T13-20-19-774Z.yml
create mode 100644 .playwright-mcp/page-2026-07-18T13-20-42-149Z.yml
create mode 100644 README.md
create mode 100644 boost.json
create mode 100644 opencode.json
diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md
new file mode 100644
index 00000000..db3558bc
--- /dev/null
+++ b/.agents/skills/developing-with-fortify/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: developing-with-fortify
+description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
+---
+
+# Laravel Fortify Development
+
+Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+
+## Documentation
+
+Use `search-docs` for detailed Laravel Fortify patterns and documentation.
+
+## Usage
+
+- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
+- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
+- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
+- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
+- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
+
+## Available Features
+
+Enable in `config/fortify.php` features array:
+
+- `Features::registration()` - User registration
+- `Features::resetPasswords()` - Password reset via email
+- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
+- `Features::updateProfileInformation()` - Profile updates
+- `Features::updatePasswords()` - Password changes
+- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
+
+> Use `search-docs` for feature configuration options and customization patterns.
+
+## Setup Workflows
+
+### Two-Factor Authentication Setup
+
+```
+- [ ] Add TwoFactorAuthenticatable trait to User model
+- [ ] Enable feature in config/fortify.php
+- [ ] Run migrations for 2FA columns
+- [ ] Set up view callbacks in FortifyServiceProvider
+- [ ] Create 2FA management UI
+- [ ] Test QR code and recovery codes
+```
+
+> Use `search-docs` for TOTP implementation and recovery code handling patterns.
+
+### Email Verification Setup
+
+```
+- [ ] Enable emailVerification feature in config
+- [ ] Implement MustVerifyEmail interface on User model
+- [ ] Set up verifyEmailView callback
+- [ ] Add verified middleware to protected routes
+- [ ] Test verification email flow
+```
+
+> Use `search-docs` for MustVerifyEmail implementation patterns.
+
+### Password Reset Setup
+
+```
+- [ ] Enable resetPasswords feature in config
+- [ ] Set up requestPasswordResetLinkView callback
+- [ ] Set up resetPasswordView callback
+- [ ] Define password.reset named route (if views disabled)
+- [ ] Test reset email and link flow
+```
+
+> Use `search-docs` for custom password reset flow patterns.
+
+### SPA Authentication Setup
+
+```
+- [ ] Set 'views' => false in config/fortify.php
+- [ ] Install and configure Laravel Sanctum
+- [ ] Use 'web' guard in fortify config
+- [ ] Set up CSRF token handling
+- [ ] Test XHR authentication flows
+```
+
+> Use `search-docs` for integration and SPA authentication patterns.
+
+## Best Practices
+
+### Custom Authentication Logic
+
+Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
+
+### Registration Customization
+
+Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
+
+### Rate Limiting
+
+Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
+
+## Key Endpoints
+
+| Feature | Method | Endpoint |
+|------------------------|----------|---------------------------------------------|
+| Login | POST | `/login` |
+| Logout | POST | `/logout` |
+| Register | POST | `/register` |
+| Password Reset Request | POST | `/forgot-password` |
+| Password Reset | POST | `/reset-password` |
+| Email Verify Notice | GET | `/email/verify` |
+| Resend Verification | POST | `/email/verification-notification` |
+| Password Confirm | POST | `/user/confirm-password` |
+| Enable 2FA | POST | `/user/two-factor-authentication` |
+| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
+| 2FA Challenge | POST | `/two-factor-challenge` |
+| Get QR Code | GET | `/user/two-factor-qr-code` |
+| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md
new file mode 100644
index 00000000..d4fb5a03
--- /dev/null
+++ b/.agents/skills/fluxui-development/SKILL.md
@@ -0,0 +1,81 @@
+---
+name: fluxui-development
+description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Flux UI Development
+
+## Documentation
+
+Use `search-docs` for detailed Flux UI patterns and documentation.
+
+## Basic Usage
+
+This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
+
+Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
+
+Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
+
+
+```blade
+Click me
+```
+
+## Available Components (Free Edition)
+
+Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
+
+## Icons
+
+Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
+
+
+```blade
+Export
+```
+
+For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
+
+```bash
+php artisan flux:icon crown grip-vertical github
+```
+
+## Common Patterns
+
+### Form Fields
+
+
+```blade
+
+ Email
+
+
+
+```
+
+### Modals
+
+
+```blade
+
+ Title
+ Content
+
+```
+
+## Verification
+
+1. Check component renders correctly
+2. Test interactive states
+3. Verify mobile responsiveness
+
+## Common Pitfalls
+
+- Trying to use Pro-only components in the free edition
+- Not checking if a Flux component exists before creating custom implementations
+- Forgetting to use the `search-docs` tool for component-specific documentation
+- Not following existing project patterns for Flux usage
diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 00000000..d136d755
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## How to Apply
+
+1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
+2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
+3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
+4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
+5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
+6. Re-read the diff against every mapped rule before finishing.
+
+## Rule Index
+
+Cross-cutting changes often need more than one rule file.
+
+| Concern | Read |
+| --- | --- |
+| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
+| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
+| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
+| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
+| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
+| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
+| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
+| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
+| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
+| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
+| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
+| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
+| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
+| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
+| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
+| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
+| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
+| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
+| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
+| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
+
+## Decision Rules
+
+- Prefer framework features and existing application abstractions over new helpers or dependencies.
+- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
+- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 00000000..f12876e4
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 00000000..138d5a48
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,202 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function handle(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```php
+class OrderService
+{
+ public function __construct(private StripeGateway $gateway) {}
+}
+```
+
+Correct (interface dependency):
+```php
+interface PaymentGateway
+{
+ public function charge(int $amount, string $customerId): PaymentResult;
+}
+
+class OrderService
+{
+ public function __construct(private PaymentGateway $gateway) {}
+}
+```
+
+Bind in a service provider:
+
+```php
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+```
+
+## Default Sort by Descending
+
+When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
+
+Incorrect:
+```php
+$posts = Post::paginate();
+```
+
+Correct:
+```php
+$posts = Post::latest()->paginate();
+```
+
+## Use Atomic Locks for Race Conditions
+
+Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
+
+```php
+Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
+ $order->process();
+});
+
+// Or at query level
+$product = Product::where('id', $id)->lockForUpdate()->first();
+```
+
+## Use `mb_*` String Functions
+
+When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
+
+Incorrect:
+```php
+strlen('José'); // 5 (bytes, not characters)
+strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
+```
+
+Correct:
+```php
+mb_strlen('José'); // 4 (characters)
+mb_strtolower('MÜNCHEN'); // 'münchen'
+
+// Prefer Laravel's Str helpers when available
+Str::length('José'); // 4
+Str::lower('MÜNCHEN'); // 'münchen'
+```
+
+## Use `defer()` for Post-Response Work
+
+For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
+
+Incorrect (job overhead for trivial work):
+```php
+dispatch(new LogPageView($page));
+```
+
+Correct (runs after response, same process):
+```php
+defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
+```
+
+Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
+
+## Use `Context` for Request-Scoped Data
+
+The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
+
+```php
+// In middleware
+Context::add('tenant_id', $request->header('X-Tenant-ID'));
+
+// Anywhere later — controllers, jobs, log context
+$tenantId = Context::get('tenant_id');
+```
+
+Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
+
+## Use `Concurrency::run()` for Parallel Execution
+
+Run independent operations in parallel using child processes — no async libraries needed.
+
+```php
+use Illuminate\Support\Facades\Concurrency;
+
+[$users, $orders] = Concurrency::run([
+ fn () => User::count(),
+ fn () => Order::where('status', 'pending')->count(),
+]);
+```
+
+Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
+
+## Convention Over Configuration
+
+Follow Laravel conventions. Don't override defaults unnecessarily.
+
+Incorrect:
+```php
+class Customer extends Model
+{
+ protected $table = 'Customer';
+ protected $primaryKey = 'customer_id';
+
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
+ }
+}
+```
+
+Correct:
+```php
+class Customer extends Model
+{
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md
new file mode 100644
index 00000000..5f0b3a1e
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/blade-views.md
@@ -0,0 +1,36 @@
+# Blade & Views Best Practices
+
+## Use `$attributes->merge()` in Component Templates
+
+Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
+
+```blade
+merge(['class' => 'alert alert-'.$type]) }}>
+ {{ $message }}
+
+```
+
+## Use `@pushOnce` for Per-Component Scripts
+
+If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
+
+## Prefer Blade Components Over `@include`
+
+`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
+
+## Use View Composers for Shared View Data
+
+If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
+
+## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
+
+A single view can return either the full page or just a fragment, keeping routing clean.
+
+```php
+return view('dashboard', compact('users'))
+ ->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
+```
+
+## Use `@aware` for Deeply Nested Component Props
+
+Avoids re-passing parent props through every level of nested components.
diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md
new file mode 100644
index 00000000..67408d6e
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/caching.md
@@ -0,0 +1,70 @@
+# Caching Best Practices
+
+## Use `Cache::remember()` Instead of Manual Get/Put
+
+Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
+
+Incorrect:
+```php
+$val = Cache::get('stats');
+if (! $val) {
+ $val = $this->computeStats();
+ Cache::put('stats', $val, 60);
+}
+```
+
+Correct:
+```php
+$val = Cache::remember('stats', 60, fn () => $this->computeStats());
+```
+
+## Use `Cache::flexible()` for Stale-While-Revalidate
+
+On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
+
+Incorrect: `Cache::remember('users', 300, fn () => User::all());`
+
+Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
+
+## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
+
+If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
+
+`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
+
+## Use Cache Tags to Invalidate Related Groups
+
+Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
+
+```php
+Cache::tags(['user-1'])->flush();
+```
+
+## Use `Cache::add()` for Atomic Conditional Writes
+
+`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
+
+Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
+
+Correct: `Cache::add('lock', true, 10);`
+
+## Use `once()` for Per-Request Memoization
+
+`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
+
+```php
+public function roles(): Collection
+{
+ return once(fn () => $this->loadRoles());
+}
+```
+
+Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
+
+## Configure Failover Cache Stores in Production
+
+If Redis goes down, the app falls back to a secondary store automatically.
+
+```php
+'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
+```
diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md
new file mode 100644
index 00000000..18e8d9e1
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/collections.md
@@ -0,0 +1,44 @@
+# Collection Best Practices
+
+## Use Higher-Order Messages for Simple Operations
+
+Incorrect:
+```php
+$users->each(function (User $user) {
+ $user->markAsVip();
+});
+```
+
+Correct: `$users->each->markAsVip();`
+
+Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
+
+## Choose `cursor()` vs. `lazy()` Correctly
+
+- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
+- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
+
+Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
+
+Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
+
+## Use `lazyById()` When Updating Records While Iterating
+
+`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
+
+## Use `toQuery()` for Bulk Operations on Collections
+
+Avoids manual `whereIn` construction.
+
+Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
+
+Correct: `$users->toQuery()->update([...]);`
+
+## Use `#[CollectedBy]` for Custom Collection Classes
+
+More declarative than overriding `newCollection()`.
+
+```php
+#[CollectedBy(UserCollection::class)]
+class User extends Model {}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md
new file mode 100644
index 00000000..9bea727b
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/config.md
@@ -0,0 +1,73 @@
+# Configuration Best Practices
+
+## `env()` Only in Config Files
+
+Direct `env()` calls may return `null` when config is cached.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'key' => env('API_KEY'),
+
+// Application code
+$key = config('services.key');
+```
+
+## Use Encrypted Env or External Secrets
+
+Never store production secrets in plain `.env` files in version control.
+
+Incorrect:
+```bash
+
+# .env committed to repo or shared in Slack
+
+STRIPE_SECRET=sk_live_abc123
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
+```
+
+Correct:
+```bash
+php artisan env:encrypt --env=production --readable
+php artisan env:decrypt --env=production
+```
+
+For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
+
+## Use `App::environment()` for Environment Checks
+
+Incorrect:
+```php
+if (env('APP_ENV') === 'production') {
+```
+
+Correct:
+```php
+if (app()->isProduction()) {
+// or
+if (App::environment('production')) {
+```
+
+## Use Constants and Language Files
+
+Use class constants instead of hardcoded magic strings for model states, types, and statuses.
+
+```php
+// Incorrect
+return $this->type === 'normal';
+
+// Correct
+return $this->type === self::TYPE_NORMAL;
+```
+
+If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
+
+```php
+// Only when lang files already exist in the project
+return back()->with('message', __('app.article_added'));
+```
diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md
new file mode 100644
index 00000000..c49ba164
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/db-performance.md
@@ -0,0 +1,192 @@
+# Database Performance Best Practices
+
+## Always Eager Load Relationships
+
+Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
+
+Incorrect (N+1 — executes 1 + N queries):
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Correct (2 queries total):
+```php
+$posts = Post::with('author')->get();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Constrain eager loads to select only needed columns (always include the foreign key):
+
+```php
+$users = User::with(['posts' => function ($query) {
+ $query->select('id', 'user_id', 'title')
+ ->where('published', true)
+ ->latest()
+ ->limit(10);
+}])->get();
+```
+
+## Prevent Lazy Loading in Development
+
+Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
+
+```php
+public function boot(): void
+{
+ Model::preventLazyLoading(! app()->isProduction());
+}
+```
+
+Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
+
+## Select Only Needed Columns
+
+Avoid `SELECT *` — especially when tables have large text or JSON columns.
+
+Incorrect:
+```php
+$posts = Post::with('author')->get();
+```
+
+Correct:
+```php
+$posts = Post::select('id', 'title', 'user_id', 'created_at')
+ ->with(['author:id,name,avatar'])
+ ->get();
+```
+
+When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
+
+## Chunk Large Datasets
+
+Never load thousands of records at once. Use chunking for batch processing.
+
+Incorrect:
+```php
+$users = User::all();
+foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+}
+```
+
+Correct:
+```php
+User::where('subscribed', true)->chunk(200, function ($users) {
+ foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+ }
+});
+```
+
+Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
+
+```php
+User::where('active', false)->chunkById(200, function ($users) {
+ $users->each->delete();
+});
+```
+
+## Add Database Indexes
+
+Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->index()->constrained();
+ $table->string('status')->index();
+ $table->timestamps();
+ $table->index(['status', 'created_at']);
+});
+```
+
+Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
+
+## Use `withCount()` for Counting Relations
+
+Never load entire collections just to count them.
+
+Incorrect:
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->comments->count();
+}
+```
+
+Correct:
+```php
+$posts = Post::withCount('comments')->get();
+foreach ($posts as $post) {
+ echo $post->comments_count;
+}
+```
+
+Conditional counting:
+
+```php
+$posts = Post::withCount([
+ 'comments',
+ 'comments as approved_comments_count' => function ($query) {
+ $query->where('approved', true);
+ },
+])->get();
+```
+
+## Use `cursor()` for Memory-Efficient Iteration
+
+For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
+
+Incorrect:
+```php
+$users = User::where('active', true)->get();
+```
+
+Correct:
+```php
+foreach (User::where('active', true)->cursor() as $user) {
+ ProcessUser::dispatch($user->id);
+}
+```
+
+Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
+
+## No Queries in Blade Templates
+
+Never execute queries in Blade templates. Pass data from controllers.
+
+Incorrect:
+```blade
+@foreach (User::all() as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
+
+Correct:
+```php
+// Controller
+$users = User::with('profile')->get();
+return view('users.index', compact('users'));
+```
+
+```blade
+@foreach ($users as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md
new file mode 100644
index 00000000..413d5da4
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/eloquent.md
@@ -0,0 +1,148 @@
+# Eloquent Best Practices
+
+## Use Correct Relationship Types
+
+Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
+
+```php
+public function comments(): HasMany
+{
+ return $this->hasMany(Comment::class);
+}
+
+public function author(): BelongsTo
+{
+ return $this->belongsTo(User::class, 'user_id');
+}
+```
+
+## Use Local Scopes for Reusable Queries
+
+Extract reusable query constraints into local scopes to avoid duplication.
+
+Incorrect:
+```php
+$active = User::where('verified', true)->whereNotNull('activated_at')->get();
+$articles = Article::whereHas('user', function ($q) {
+ $q->where('verified', true)->whereNotNull('activated_at');
+})->get();
+```
+
+Correct:
+```php
+public function scopeActive(Builder $query): Builder
+{
+ return $query->where('verified', true)->whereNotNull('activated_at');
+}
+
+// Usage
+$active = User::active()->get();
+$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
+```
+
+## Apply Global Scopes Sparingly
+
+Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
+
+Incorrect (global scope for a conditional filter):
+```php
+class PublishedScope implements Scope
+{
+ public function apply(Builder $builder, Model $model): void
+ {
+ $builder->where('published', true);
+ }
+}
+// Now admin panels, reports, and background jobs all silently skip drafts
+```
+
+Correct (local scope you opt into):
+```php
+public function scopePublished(Builder $query): Builder
+{
+ return $query->where('published', true);
+}
+
+Post::published()->paginate(); // Explicit
+Post::paginate(); // Admin sees all
+```
+
+## Define Attribute Casts
+
+Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
+
+```php
+protected function casts(): array
+{
+ return [
+ 'is_active' => 'boolean',
+ 'metadata' => 'array',
+ 'total' => 'decimal:2',
+ ];
+}
+```
+
+## Cast Date Columns Properly
+
+Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
+
+Incorrect:
+```blade
+{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
+```
+
+Correct:
+```php
+protected function casts(): array
+{
+ return [
+ 'ordered_at' => 'datetime',
+ ];
+}
+```
+
+```blade
+{{ $order->ordered_at->toDateString() }}
+{{ $order->ordered_at->format('m-d') }}
+```
+
+## Use `whereBelongsTo()` for Relationship Queries
+
+Cleaner than manually specifying foreign keys.
+
+Incorrect:
+```php
+Post::where('user_id', $user->id)->get();
+```
+
+Correct:
+```php
+Post::whereBelongsTo($user)->get();
+Post::whereBelongsTo($user, 'author')->get();
+```
+
+## Avoid Hardcoded Table Names in Queries
+
+Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
+
+Incorrect:
+```php
+DB::table('users')->where('active', true)->get();
+
+$query->join('companies', 'companies.id', '=', 'users.company_id');
+
+DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
+```
+
+Correct — reference the model's table:
+```php
+DB::table((new User)->getTable())->where('active', true)->get();
+
+// Even better — use Eloquent or the query builder instead of raw SQL
+User::where('active', true)->get();
+Order::where('status', 'pending')->get();
+```
+
+Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
+
+**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md
new file mode 100644
index 00000000..4b148667
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/error-handling.md
@@ -0,0 +1,72 @@
+# Error Handling Best Practices
+
+## Exception Reporting and Rendering
+
+There are two valid approaches — choose one and apply it consistently across the project.
+
+**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function report(): void { /* custom reporting */ }
+
+ public function render(Request $request): Response
+ {
+ return response()->view('errors.invalid-order', status: 422);
+ }
+}
+```
+
+**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
+
+```php
+->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->report(function (InvalidOrderException $e) { /* ... */ });
+ $exceptions->render(function (InvalidOrderException $e, Request $request) {
+ return response()->view('errors.invalid-order', status: 422);
+ });
+})
+```
+
+Check the existing codebase and follow whichever pattern is already established.
+
+## Use `ShouldntReport` for Exceptions That Should Never Log
+
+More discoverable than listing classes in `dontReport()`.
+
+```php
+class PodcastProcessingException extends Exception implements ShouldntReport {}
+```
+
+## Throttle High-Volume Exceptions
+
+A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
+
+## Enable `dontReportDuplicates()`
+
+Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
+
+## Force JSON Error Rendering for API Routes
+
+Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
+
+```php
+$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
+ return $request->is('api/*') || $request->expectsJson();
+});
+```
+
+## Add Context to Exception Classes
+
+Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function context(): array
+ {
+ return ['order_id' => $this->orderId];
+ }
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md
new file mode 100644
index 00000000..82e329e8
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md
@@ -0,0 +1,52 @@
+# Events & Notifications Best Practices
+
+## Rely on Event Discovery
+
+Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
+
+## Run `event:cache` in Production Deploy
+
+Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
+
+## Use `ShouldDispatchAfterCommit` Inside Transactions
+
+Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
+
+```php
+class OrderShipped implements ShouldDispatchAfterCommit {}
+```
+
+## Always Queue Notifications
+
+Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
+
+```php
+class InvoicePaid extends Notification implements ShouldQueue
+{
+ use Queueable;
+}
+```
+
+## Use `afterCommit()` on Notifications in Transactions
+
+Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
+
+```php
+$user->notify((new InvoicePaid($invoice))->afterCommit());
+```
+
+## Route Notification Channels to Dedicated Queues
+
+Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
+
+## Use On-Demand Notifications for Non-User Recipients
+
+Avoid creating dummy models to send notifications to arbitrary addresses.
+
+```php
+Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
+```
+
+## Implement `HasLocalePreference` on Notifiable Models
+
+Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md
new file mode 100644
index 00000000..8e2f16e8
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/http-client.md
@@ -0,0 +1,160 @@
+# HTTP Client Best Practices
+
+## Always Set Explicit Timeouts
+
+The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users');
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->connectTimeout(3)
+ ->get('https://api.example.com/users');
+```
+
+For service-specific clients, define timeouts in a macro:
+
+```php
+Http::macro('github', function () {
+ return Http::baseUrl('https://api.github.com')
+ ->timeout(10)
+ ->connectTimeout(3)
+ ->withToken(config('services.github.token'));
+});
+
+$response = Http::github()->get('/repos/laravel/framework');
+```
+
+## Use Retry with Backoff for External APIs
+
+External APIs have transient failures. Use `retry()` with increasing delays.
+
+Incorrect:
+```php
+$response = Http::post('https://api.stripe.com/v1/charges', $data);
+
+if ($response->failed()) {
+ throw new PaymentFailedException('Charge failed');
+}
+```
+
+Correct:
+```php
+$response = Http::retry([100, 500, 1000])
+ ->timeout(10)
+ ->post('https://api.stripe.com/v1/charges', $data);
+```
+
+Only retry on specific errors:
+
+```php
+$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
+ return $exception instanceof ConnectionException
+ || ($exception instanceof RequestException && $exception->response->serverError());
+})->post('https://api.example.com/data');
+```
+
+## Handle Errors Explicitly
+
+The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users/1');
+$user = $response->json(); // Could be an error body
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->get('https://api.example.com/users/1')
+ ->throw();
+
+$user = $response->json();
+```
+
+For graceful degradation:
+
+```php
+$response = Http::get('https://api.example.com/users/1');
+
+if ($response->successful()) {
+ return $response->json();
+}
+
+if ($response->notFound()) {
+ return null;
+}
+
+$response->throw();
+```
+
+## Use Request Pooling for Concurrent Requests
+
+When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
+
+Incorrect:
+```php
+$users = Http::get('https://api.example.com/users')->json();
+$posts = Http::get('https://api.example.com/posts')->json();
+$comments = Http::get('https://api.example.com/comments')->json();
+```
+
+Correct:
+```php
+use Illuminate\Http\Client\Pool;
+
+$responses = Http::pool(fn (Pool $pool) => [
+ $pool->as('users')->get('https://api.example.com/users'),
+ $pool->as('posts')->get('https://api.example.com/posts'),
+ $pool->as('comments')->get('https://api.example.com/comments'),
+]);
+
+$users = $responses['users']->json();
+$posts = $responses['posts']->json();
+```
+
+## Fake HTTP Calls in Tests
+
+Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
+
+Incorrect:
+```php
+it('syncs user from API', function () {
+ $service = new UserSyncService;
+ $service->sync(1); // Hits the real API
+});
+```
+
+Correct:
+```php
+it('syncs user from API', function () {
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'api.example.com/users/1' => Http::response([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ ]),
+ ]);
+
+ $service = new UserSyncService;
+ $service->sync(1);
+
+ Http::assertSent(function (Request $request) {
+ return $request->url() === 'https://api.example.com/users/1';
+ });
+});
+```
+
+Test failure scenarios too:
+
+```php
+Http::fake([
+ 'api.example.com/*' => Http::failedConnection(),
+]);
+```
diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md
new file mode 100644
index 00000000..7c717336
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/mail.md
@@ -0,0 +1,27 @@
+# Mail Best Practices
+
+## Implement `ShouldQueue` on the Mailable Class
+
+Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
+
+## Use `afterCommit()` on Mailables Inside Transactions
+
+A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
+
+## Use `assertQueued()` Not `assertSent()` for Queued Mailables
+
+`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
+
+Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
+
+Correct: `Mail::assertQueued(OrderShipped::class);`
+
+## Use Markdown Mailables for Transactional Emails
+
+Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
+
+## Separate Content Tests from Sending Tests
+
+Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
+Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
+Don't mix them — it conflates concerns and makes tests brittle.
diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md
new file mode 100644
index 00000000..df6f5f33
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/migrations.md
@@ -0,0 +1,121 @@
+# Migration Best Practices
+
+## Generate Migrations with Artisan
+
+Always use `php artisan make:migration` for consistent naming and timestamps.
+
+Incorrect (manually created file):
+```php
+// database/migrations/posts_migration.php ← wrong naming, no timestamp
+```
+
+Correct (Artisan-generated):
+```bash
+php artisan make:migration create_posts_table
+php artisan make:migration add_slug_to_posts_table
+```
+
+## Use `constrained()` for Foreign Keys
+
+Automatic naming and referential integrity.
+
+```php
+$table->foreignId('user_id')->constrained()->cascadeOnDelete();
+
+// Non-standard names
+$table->foreignId('author_id')->constrained('users');
+```
+
+## Never Modify Deployed Migrations
+
+Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
+
+Incorrect (editing a deployed migration):
+```php
+// 2024_01_01_create_posts_table.php — already in production
+$table->string('slug')->unique(); // ← added after deployment
+```
+
+Correct (new migration to alter):
+```php
+// 2024_03_15_add_slug_to_posts_table.php
+Schema::table('posts', function (Blueprint $table) {
+ $table->string('slug')->unique()->after('title');
+});
+```
+
+## Add Indexes in the Migration
+
+Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->index();
+ $table->string('status')->index();
+ $table->timestamp('shipped_at')->nullable()->index();
+ $table->timestamps();
+});
+```
+
+## Mirror Defaults in Model `$attributes`
+
+When a column has a database default, mirror it in the model so new instances have correct values before saving.
+
+```php
+// Migration
+$table->string('status')->default('pending');
+
+// Model
+protected $attributes = [
+ 'status' => 'pending',
+];
+```
+
+## Write Reversible `down()` Methods by Default
+
+Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
+
+```php
+public function down(): void
+{
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('slug');
+ });
+}
+```
+
+For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
+
+## Keep Migrations Focused
+
+One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
+
+Incorrect (partial failure creates unrecoverable state):
+```php
+public function up(): void
+{
+ Schema::create('settings', function (Blueprint $table) { ... });
+ DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+}
+```
+
+Correct (separate migrations):
+```php
+// Migration 1: create_settings_table
+Schema::create('settings', function (Blueprint $table) { ... });
+
+// Migration 2: seed_default_settings
+DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+```
diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md
new file mode 100644
index 00000000..c41915e2
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md
@@ -0,0 +1,144 @@
+# Queue & Job Best Practices
+
+## Set `retry_after` Greater Than `timeout`
+
+If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
+
+Incorrect (`retry_after` ≤ `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 90 ← job retried while still running!
+```
+
+Correct (`retry_after` > `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 180 ← safely longer than any job timeout
+```
+
+## Use Exponential Backoff
+
+Use progressively longer delays between retries to avoid hammering failing services.
+
+Incorrect (fixed retry interval):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ // Default: retries immediately, overwhelming the API
+}
+```
+
+Correct (exponential backoff):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ public $backoff = [1, 5, 10];
+}
+```
+
+## Implement `ShouldBeUnique`
+
+Prevent duplicate job processing.
+
+```php
+class GenerateInvoice implements ShouldQueue, ShouldBeUnique
+{
+ public function uniqueId(): string
+ {
+ return $this->order->id;
+ }
+
+ public $uniqueFor = 3600;
+}
+```
+
+## Always Implement `failed()`
+
+Handle errors explicitly — don't rely on silent failure.
+
+```php
+public function failed(?Throwable $exception): void
+{
+ $this->podcast->update(['status' => 'failed']);
+ Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
+}
+```
+
+## Rate Limit External API Calls in Jobs
+
+Use `RateLimited` middleware to throttle jobs calling third-party APIs.
+
+```php
+public function middleware(): array
+{
+ return [new RateLimited('external-api')];
+}
+```
+
+## Batch Related Jobs
+
+Use `Bus::batch()` when jobs should succeed or fail together.
+
+```php
+Bus::batch([
+ new ImportCsvChunk($chunk1),
+ new ImportCsvChunk($chunk2),
+])
+->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
+->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
+->dispatch();
+```
+
+## `retryUntil()` Needs `$tries = 0`
+
+When using time-based retry limits, set `$tries = 0` to avoid premature failure.
+
+```php
+public $tries = 0;
+
+public function retryUntil(): \DateTimeInterface
+{
+ return now()->addHours(4);
+}
+```
+
+## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
+
+`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
+
+```php
+class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
+{
+ // Lock releases when processing begins, not when it finishes
+}
+```
+
+## Use Horizon for Complex Queue Scenarios
+
+Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
+
+```php
+// config/horizon.php
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['high', 'default', 'low'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+ ],
+],
+```
diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md
new file mode 100644
index 00000000..b6e30864
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/routing.md
@@ -0,0 +1,99 @@
+# Routing & Controllers Best Practices
+
+## Use Implicit Route Model Binding
+
+Let Laravel resolve models automatically from route parameters.
+
+Incorrect:
+```php
+public function show(int $id)
+{
+ $post = Post::findOrFail($id);
+}
+```
+
+Correct:
+```php
+public function show(Post $post)
+{
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+## Use Scoped Bindings for Nested Resources
+
+Enforce parent-child relationships automatically.
+
+```php
+Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
+ // $post is automatically scoped to $user
+})->scopeBindings();
+```
+
+## Use Resource Controllers
+
+Use `Route::resource()` or `apiResource()` for RESTful endpoints.
+
+```php
+Route::resource('posts', PostController::class);
+// In routes/api.php — the /api prefix is applied automatically
+Route::apiResource('posts', Api\PostController::class);
+```
+
+## Keep Controllers Thin
+
+Aim for under 10 lines per method. Extract business logic to action or service classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([...]);
+ if ($request->hasFile('image')) {
+ $request->file('image')->move(public_path('images'));
+ }
+ $post = Post::create($validated);
+ $post->tags()->sync($validated['tags']);
+ event(new PostCreated($post));
+ return redirect()->route('posts.show', $post);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request, CreatePostAction $create)
+{
+ $post = $create->execute($request->validated());
+
+ return redirect()->route('posts.show', $post);
+}
+```
+
+## Type-Hint Form Requests
+
+Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
+
+Incorrect:
+```php
+public function store(Request $request): RedirectResponse
+{
+ $validated = $request->validate([
+ 'title' => ['required', 'max:255'],
+ 'body' => ['required'],
+ ]);
+
+ Post::create($validated);
+
+ return redirect()->route('posts.index');
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request): RedirectResponse
+{
+ Post::create($request->validated());
+
+ return redirect()->route('posts.index');
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md
new file mode 100644
index 00000000..a9847945
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/scheduling.md
@@ -0,0 +1,39 @@
+# Task Scheduling Best Practices
+
+## Use `withoutOverlapping()` on Variable-Duration Tasks
+
+Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
+
+## Use `onOneServer()` on Multi-Server Deployments
+
+Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
+
+## Use `runInBackground()` for Concurrent Long Tasks
+
+By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
+
+## Use `environments()` to Restrict Tasks
+
+Prevent accidental execution of production-only tasks (billing, reporting) on staging.
+
+```php
+Schedule::command('billing:charge')->monthly()->environments(['production']);
+```
+
+## Use `takeUntilTimeout()` for Time-Bounded Processing
+
+A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
+
+## Use Schedule Groups for Shared Configuration
+
+Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
+
+```php
+Schedule::daily()
+ ->onOneServer()
+ ->timezone('America/New_York')
+ ->group(function () {
+ Schedule::command('emails:send --force');
+ Schedule::command('emails:prune');
+ });
+```
diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md
new file mode 100644
index 00000000..2d7200c2
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/security.md
@@ -0,0 +1,198 @@
+# Security Best Practices
+
+## Mass Assignment Protection
+
+Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
+
+Incorrect:
+```php
+class User extends Model
+{
+ protected $guarded = []; // All fields are mass assignable
+}
+```
+
+Correct:
+```php
+class User extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+}
+```
+
+Never use `$guarded = []` on models that accept user input.
+
+## Authorize Every Action
+
+Use policies or gates in controllers. Never skip authorization.
+
+Incorrect:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ $post->update($request->validated());
+}
+```
+
+Correct:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ Gate::authorize('update', $post);
+
+ $post->update($request->validated());
+}
+```
+
+Or via Form Request:
+
+```php
+public function authorize(): bool
+{
+ return $this->user()->can('update', $this->route('post'));
+}
+```
+
+## Prevent SQL Injection
+
+Always use parameter binding. Never interpolate user input into queries.
+
+Incorrect:
+```php
+DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
+```
+
+Correct:
+```php
+User::where('name', $request->name)->get();
+
+// Raw expressions with bindings
+User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
+```
+
+## Escape Output to Prevent XSS
+
+Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
+
+Incorrect:
+```blade
+{!! $user->bio !!}
+```
+
+Correct:
+```blade
+{{ $user->bio }}
+```
+
+## CSRF Protection
+
+Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
+
+Incorrect:
+```blade
+
+```
+
+Correct:
+```blade
+
+```
+
+## Rate Limit Auth and API Routes
+
+Apply `throttle` middleware to authentication and API routes.
+
+```php
+RateLimiter::for('login', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+});
+
+Route::post('/login', LoginController::class)->middleware('throttle:login');
+```
+
+## Validate File Uploads
+
+Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
+
+```php
+public function rules(): array
+{
+ return [
+ 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+ ];
+}
+```
+
+Store with generated filenames:
+
+```php
+$path = $request->file('avatar')->store('avatars', 'public');
+```
+
+## Keep Secrets Out of Code
+
+Never commit `.env`. Access secrets via `config()` only.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'api_key' => env('API_KEY'),
+
+// In application code
+$key = config('services.api_key');
+```
+
+## Audit Dependencies
+
+Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
+
+```bash
+composer audit
+```
+
+## Encrypt Sensitive Database Fields
+
+Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
+
+Incorrect:
+```php
+class Integration extends Model
+{
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'string',
+ ];
+ }
+}
+```
+
+Correct:
+```php
+class Integration extends Model
+{
+ protected $hidden = ['api_key', 'api_secret'];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'encrypted',
+ 'api_secret' => 'encrypted',
+ ];
+ }
+}
+```
diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md
new file mode 100644
index 00000000..a8afb369
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/style.md
@@ -0,0 +1,125 @@
+# Conventions & Style
+
+## Follow Laravel Naming Conventions
+
+| What | Convention | Good | Bad |
+|------|-----------|------|-----|
+| Controller | singular | `ArticleController` | `ArticlesController` |
+| Model | singular | `User` | `Users` |
+| Table | plural, snake_case | `article_comments` | `articleComments` |
+| Pivot table | singular alphabetical | `article_user` | `user_article` |
+| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
+| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
+| Route | plural | `articles/1` | `article/1` |
+| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
+| Method | camelCase | `getAll` | `get_all` |
+| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
+| Collection | descriptive, plural | `$activeUsers` | `$data` |
+| Object | descriptive, singular | `$activeUser` | `$users` |
+| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
+| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
+| Enum | singular | `UserType` | `UserTypes` |
+
+## Prefer Shorter Readable Syntax
+
+| Verbose | Shorter |
+|---------|---------|
+| `Session::get('cart')` | `session('cart')` |
+| `$request->session()->get('cart')` | `session('cart')` |
+| `$request->input('name')` | `$request->name` |
+| `return Redirect::back()` | `return back()` |
+| `Carbon::now()` | `now()` |
+| `App::make('Class')` | `app('Class')` |
+| `->where('column', '=', 1)` | `->where('column', 1)` |
+| `->orderBy('created_at', 'desc')` | `->latest()` |
+| `->orderBy('created_at', 'asc')` | `->oldest()` |
+| `->first()->name` | `->value('name')` |
+
+## Use Laravel String & Array Helpers
+
+Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
+
+Strings — use `Str` and fluent `Str::of()` over raw PHP:
+```php
+// Incorrect
+$slug = strtolower(str_replace(' ', '-', $title));
+$short = substr($text, 0, 100) . '...';
+$class = substr(strrchr('App\Models\User', '\\'), 1);
+
+// Correct
+$slug = Str::slug($title);
+$short = Str::limit($text, 100);
+$class = class_basename('App\Models\User');
+```
+
+Fluent strings — chain operations for complex transformations:
+```php
+// Incorrect
+$result = strtolower(trim(str_replace('_', '-', $input)));
+
+// Correct
+$result = Str::of($input)->trim()->replace('_', '-')->lower();
+```
+
+Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
+
+Arrays — use `Arr` over raw PHP:
+```php
+// Incorrect
+$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
+
+// Correct
+$name = Arr::get($array, 'user.name', 'default');
+```
+
+Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
+
+Numbers — use `Number` for display formatting:
+```php
+Number::format(1000000); // "1,000,000"
+Number::currency(1500, 'USD'); // "$1,500.00"
+Number::abbreviate(1000000); // "1M"
+Number::fileSize(1024 * 1024); // "1 MB"
+Number::percentage(75.5); // "75.5%"
+```
+
+URIs — use `Uri` for URL manipulation:
+```php
+$uri = Uri::of('https://example.com/search')
+ ->withQuery(['q' => 'laravel', 'page' => 1]);
+```
+
+Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
+
+Use `search-docs` for the full list of available methods — these helpers are extensive.
+
+## No Inline JS/CSS in Blade
+
+Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
+
+Incorrect:
+```blade
+let article = `{{ json_encode($article) }}`;
+```
+
+Correct:
+```blade
+{{ $article->name }}
+```
+
+Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
+
+## No Unnecessary Comments
+
+Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
+
+Incorrect:
+```php
+// Check if there are any joins
+if (count((array) $builder->getQuery()->joins) > 0)
+```
+
+Correct:
+```php
+if ($this->hasJoins())
+```
diff --git a/.agents/skills/laravel-best-practices/rules/testing.md b/.agents/skills/laravel-best-practices/rules/testing.md
new file mode 100644
index 00000000..4fbf12f8
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/testing.md
@@ -0,0 +1,43 @@
+# Testing Best Practices
+
+## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
+
+`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
+
+## Use Model Assertions Over Raw Database Assertions
+
+Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
+
+Correct: `$this->assertModelExists($user);`
+
+More expressive, type-safe, and fails with clearer messages.
+
+## Use Factory States and Sequences
+
+Named states make tests self-documenting. Sequences eliminate repetitive setup.
+
+Incorrect: `User::factory()->create(['email_verified_at' => null]);`
+
+Correct: `User::factory()->unverified()->create();`
+
+## Use `Exceptions::fake()` to Assert Exception Reporting
+
+Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
+
+## Call `Event::fake()` After Factory Setup
+
+Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
+
+Incorrect: `Event::fake(); $user = User::factory()->create();`
+
+Correct: `$user = User::factory()->create(); Event::fake();`
+
+## Use `recycle()` to Share Relationship Instances Across Factories
+
+Without `recycle()`, nested factories create separate instances of the same conceptual entity.
+
+```php
+Ticket::factory()
+ ->recycle(Airline::factory()->create())
+ ->create();
+```
diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md
new file mode 100644
index 00000000..5fde1064
--- /dev/null
+++ b/.agents/skills/laravel-best-practices/rules/validation.md
@@ -0,0 +1,75 @@
+# Validation & Forms Best Practices
+
+## Use Form Request Classes
+
+Extract validation from controllers into dedicated Form Request classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $request->validate([
+ 'title' => 'required|max:255',
+ 'body' => 'required',
+ ]);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request)
+{
+ Post::create($request->validated());
+}
+```
+
+## Array vs. String Notation for Rules
+
+Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
+
+```php
+// Preferred for new code
+'email' => ['required', 'email', Rule::unique('users')],
+
+// Follow existing convention if the project uses string notation
+'email' => 'required|email|unique:users',
+```
+
+## Always Use `validated()`
+
+Get only validated data. Never use `$request->all()` for mass operations.
+
+Incorrect:
+```php
+Post::create($request->all());
+```
+
+Correct:
+```php
+Post::create($request->validated());
+```
+
+## Use `Rule::when()` for Conditional Validation
+
+```php
+'company_name' => [
+ Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
+],
+```
+
+## Use the `after()` Method for Custom Validation
+
+Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
+
+```php
+public function after(): array
+{
+ return [
+ function (Validator $validator) {
+ if ($this->quantity > Product::find($this->product_id)?->stock) {
+ $validator->errors()->add('quantity', 'Not enough stock.');
+ }
+ },
+ ];
+}
+```
diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md
new file mode 100644
index 00000000..0ae356e5
--- /dev/null
+++ b/.agents/skills/livewire-development/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: livewire-development
+description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Livewire Development
+
+## Documentation
+
+Use `search-docs` for detailed Livewire 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Components
+
+```bash
+
+# Single-file component (SFC - default in v4)
+
+# Creates: resources/views/components/⚡create-post.blade.php
+
+php artisan make:livewire create-post
+
+# Page component (SFC - Full Page in v4)
+
+# Creates: resources/views/pages/⚡create-post.blade.php
+
+php artisan make:livewire pages::create-post
+
+# Multi-file component (MFC)
+
+# Creates: resources/views/components/⚡create-post/create-post.php
+
+# resources/views/components/⚡create-post/create-post.blade.php
+
+php artisan make:livewire create-post --mfc
+
+# Class-based component (v3 style)
+
+# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
+
+php artisan make:livewire create-post --class
+
+# With namespace
+
+php artisan make:livewire Posts/CreatePost
+```
+
+### Converting Between Formats
+
+Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
+
+### Choosing a Component Format
+
+> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
+
+Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
+
+### Component Format Reference
+
+| Format | Flag | Class Path | View Path |
+|--------|------|------------|-----------|
+| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
+| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
+| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
+| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
+| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
+
+> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
+
+Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
+
+### Single-File Component Example
+
+
+```php
+count++;
+ }
+};
+?>
+
+
+ Count: @{{ $count }}
+
+```
+
+## Livewire 4 Specifics
+
+### Key Changes From Livewire 3
+
+These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
+
+- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
+- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
+- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
+- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
+
+### New Features
+
+- Component formats: single-file (SFC), multi-file (MFC), view-based components.
+- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
+- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
+
+| Feature | Usage | Purpose |
+|---------|-------|---------|
+| Islands | `@island(name: 'stats')` | Isolated update regions |
+| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
+| Deferred | `defer` attribute | Load after page render |
+| Bundled | `lazy.bundle` | Load multiple together |
+
+### New Directives
+
+- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
+- `data-loading` attribute automatically added to elements triggering network requests.
+
+| Directive | Purpose |
+|-----------|---------|
+| `wire:sort` | Drag-and-drop sorting |
+| `wire:intersect` | Viewport intersection detection |
+| `wire:ref` | Element references for JS |
+| `.renderless` | Component without rendering |
+| `.preserve-scroll` | Preserve scroll position |
+
+## Best Practices
+
+- Always use `wire:key` in loops
+- Use `wire:loading` for loading states
+- Use `wire:model.live` for instant updates (default is debounced)
+- Validate and authorize in actions (treat like HTTP requests)
+
+## Configuration
+
+- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
+
+## Alpine & JavaScript
+
+- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
+- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
+
+For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
+
+## Testing
+
+
+```php
+Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1);
+```
+
+## Verification
+
+1. Browser console: Check for JS errors
+2. Network tab: Verify Livewire requests return 200
+3. Ensure `wire:key` on all `@foreach` loops
+
+## Common Pitfalls
+
+- Missing `wire:key` in loops → unexpected re-rendering
+- Expecting `wire:model` real-time → use `wire:model.live`
+- Unclosed component tags → syntax errors in v4
+- Using deprecated config keys or JS hooks
+- Including Alpine.js separately (already bundled in Livewire 4)
diff --git a/.agents/skills/livewire-development/reference/javascript-hooks.md b/.agents/skills/livewire-development/reference/javascript-hooks.md
new file mode 100644
index 00000000..660d66b5
--- /dev/null
+++ b/.agents/skills/livewire-development/reference/javascript-hooks.md
@@ -0,0 +1,39 @@
+# Livewire 4 JavaScript Integration
+
+## Interceptor System (v4)
+
+### Intercept Messages
+
+```js
+Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
+ onFinish(() => { /* After response, before processing */ });
+ onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
+ onError(() => { /* Server errors */ });
+});
+```
+
+### Intercept Requests
+
+```js
+Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
+ onResponse(({ response }) => { /* When received */ });
+ onSuccess(({ response, responseJson }) => { /* Success */ });
+ onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
+ onFailure(({ error }) => { /* Network failures */ });
+});
+```
+
+### Component-Scoped Interceptors
+
+```blade
+
+```
+
+## Magic Properties
+
+- `$errors` - Access validation errors from JavaScript
+- `$intercept` - Component-scoped interceptors
diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md
new file mode 100644
index 00000000..ab271616
--- /dev/null
+++ b/.agents/skills/pest-testing/SKILL.md
@@ -0,0 +1,166 @@
+---
+name: pest-testing
+description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Pest Testing 4
+
+## Documentation
+
+Use `search-docs` for detailed Pest 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Tests
+
+All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
+
+The `{name}` argument should include only the path and test name, but should not include the test suite.
+- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
+- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
+- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
+- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
+
+### Test Organization
+
+- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
+- Browser tests: `tests/Browser/` directory.
+- Do NOT remove tests without approval - these are core application code.
+
+### Basic Test Structure
+
+Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
+
+
+```php
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+```
+
+### Running Tests
+
+- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
+- Run all tests: `php artisan test --compact`.
+- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+
+## Assertions
+
+Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
+
+
+```php
+it('returns all', function () {
+ $this->postJson('/api/docs', [])->assertSuccessful();
+});
+```
+
+| Use | Instead of |
+|-----|------------|
+| `assertSuccessful()` | `assertStatus(200)` |
+| `assertNotFound()` | `assertStatus(404)` |
+| `assertForbidden()` | `assertStatus(403)` |
+
+## Mocking
+
+Import mock function before use: `use function Pest\Laravel\mock;`
+
+## Datasets
+
+Use datasets for repetitive tests (validation rules, etc.):
+
+
+```php
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+```
+
+## Pest 4 Features
+
+| Feature | Purpose |
+|---------|---------|
+| Browser Testing | Full integration tests in real browsers |
+| Smoke Testing | Validate multiple pages quickly |
+| Visual Regression | Compare screenshots for visual changes |
+| Test Sharding | Parallel CI runs |
+| Architecture Testing | Enforce code conventions |
+
+### Browser Test Example
+
+Browser tests run in real browsers for full integration testing:
+
+- Browser tests live in `tests/Browser/`.
+- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
+- Use `RefreshDatabase` for clean state per test.
+- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
+- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
+- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging.
+
+
+```php
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in');
+
+ $page->assertSee('Sign In')
+ ->assertNoJavaScriptErrors()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!');
+
+ Notification::assertSent(ResetPassword::class);
+});
+```
+
+### Smoke Testing
+
+Quickly validate multiple pages have no JavaScript errors:
+
+
+```php
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
+```
+
+### Visual Regression Testing
+
+Capture and compare screenshots to detect visual changes.
+
+### Test Sharding
+
+Split tests across parallel processes for faster CI runs.
+
+### Architecture Testing
+
+Pest 4 includes architecture testing (from Pest 3):
+
+
+```php
+arch('controllers')
+ ->expect('App\Http\Controllers')
+ ->toExtendNothing()
+ ->toHaveSuffix('Controller');
+```
+
+## Common Pitfalls
+
+- Not importing `use function Pest\Laravel\mock;` before using mock
+- Using `assertStatus(200)` instead of `assertSuccessful()`
+- Forgetting datasets for repetitive validation tests
+- Deleting tests without approval
+- Forgetting `assertNoJavaScriptErrors()` in browser tests
+- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md
new file mode 100644
index 00000000..c0cb2fbc
--- /dev/null
+++ b/.agents/skills/tailwindcss-development/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: tailwindcss-development
+description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Tailwind CSS Development
+
+## Documentation
+
+Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
+
+## Basic Usage
+
+- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
+- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
+- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
+
+## Tailwind CSS v4 Specifics
+
+- Always use Tailwind CSS v4 and avoid deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+
+### CSS-First Configuration
+
+In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
+
+
+```css
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+```
+
+### Import Syntax
+
+In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
+
+
+```diff
+- @tailwind base;
+- @tailwind components;
+- @tailwind utilities;
++ @import "tailwindcss";
+```
+
+### Replaced Utilities
+
+Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
+
+| Deprecated | Replacement |
+|------------|-------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+## Spacing
+
+Use `gap` utilities instead of margins for spacing between siblings:
+
+
+```html
+
+```
+
+## Dark Mode
+
+If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
+
+
+```html
+
+ Content adapts to color scheme
+
+```
+
+## Common Patterns
+
+### Flexbox Layout
+
+
+```html
+
+
Left content
+
Right content
+
+```
+
+### Grid Layout
+
+
+```html
+
+
Card 1
+
Card 2
+
Card 3
+
+```
+
+## Common Pitfalls
+
+- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
+- Using `@tailwind` directives instead of `@import "tailwindcss"`
+- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
+- Using margins for spacing between siblings instead of gap utilities
+- Forgetting to add dark mode variants when the project uses dark mode
diff --git a/.claude/skills/developing-with-fortify/SKILL.md b/.claude/skills/developing-with-fortify/SKILL.md
new file mode 100644
index 00000000..db3558bc
--- /dev/null
+++ b/.claude/skills/developing-with-fortify/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: developing-with-fortify
+description: Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
+---
+
+# Laravel Fortify Development
+
+Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+
+## Documentation
+
+Use `search-docs` for detailed Laravel Fortify patterns and documentation.
+
+## Usage
+
+- **Routes**: Use `list-routes` with `only_vendor: true` and `action: "Fortify"` to see all registered endpoints
+- **Actions**: Check `app/Actions/Fortify/` for customizable business logic (user creation, password validation, etc.)
+- **Config**: See `config/fortify.php` for all options including features, guards, rate limiters, and username field
+- **Contracts**: Look in `Laravel\Fortify\Contracts\` for overridable response classes (`LoginResponse`, `LogoutResponse`, etc.)
+- **Views**: All view callbacks are set in `FortifyServiceProvider::boot()` using `Fortify::loginView()`, `Fortify::registerView()`, etc.
+
+## Available Features
+
+Enable in `config/fortify.php` features array:
+
+- `Features::registration()` - User registration
+- `Features::resetPasswords()` - Password reset via email
+- `Features::emailVerification()` - Requires User to implement `MustVerifyEmail`
+- `Features::updateProfileInformation()` - Profile updates
+- `Features::updatePasswords()` - Password changes
+- `Features::twoFactorAuthentication()` - 2FA with QR codes and recovery codes
+
+> Use `search-docs` for feature configuration options and customization patterns.
+
+## Setup Workflows
+
+### Two-Factor Authentication Setup
+
+```
+- [ ] Add TwoFactorAuthenticatable trait to User model
+- [ ] Enable feature in config/fortify.php
+- [ ] Run migrations for 2FA columns
+- [ ] Set up view callbacks in FortifyServiceProvider
+- [ ] Create 2FA management UI
+- [ ] Test QR code and recovery codes
+```
+
+> Use `search-docs` for TOTP implementation and recovery code handling patterns.
+
+### Email Verification Setup
+
+```
+- [ ] Enable emailVerification feature in config
+- [ ] Implement MustVerifyEmail interface on User model
+- [ ] Set up verifyEmailView callback
+- [ ] Add verified middleware to protected routes
+- [ ] Test verification email flow
+```
+
+> Use `search-docs` for MustVerifyEmail implementation patterns.
+
+### Password Reset Setup
+
+```
+- [ ] Enable resetPasswords feature in config
+- [ ] Set up requestPasswordResetLinkView callback
+- [ ] Set up resetPasswordView callback
+- [ ] Define password.reset named route (if views disabled)
+- [ ] Test reset email and link flow
+```
+
+> Use `search-docs` for custom password reset flow patterns.
+
+### SPA Authentication Setup
+
+```
+- [ ] Set 'views' => false in config/fortify.php
+- [ ] Install and configure Laravel Sanctum
+- [ ] Use 'web' guard in fortify config
+- [ ] Set up CSRF token handling
+- [ ] Test XHR authentication flows
+```
+
+> Use `search-docs` for integration and SPA authentication patterns.
+
+## Best Practices
+
+### Custom Authentication Logic
+
+Override authentication behavior using `Fortify::authenticateUsing()` for custom user retrieval or `Fortify::authenticateThrough()` to customize the authentication pipeline. Override response contracts in `AppServiceProvider` for custom redirects.
+
+### Registration Customization
+
+Modify `app/Actions/Fortify/CreateNewUser.php` to customize user creation logic, validation rules, and additional fields.
+
+### Rate Limiting
+
+Configure via `fortify.limiters.login` in config. Default configuration throttles by username + IP combination.
+
+## Key Endpoints
+
+| Feature | Method | Endpoint |
+|------------------------|----------|---------------------------------------------|
+| Login | POST | `/login` |
+| Logout | POST | `/logout` |
+| Register | POST | `/register` |
+| Password Reset Request | POST | `/forgot-password` |
+| Password Reset | POST | `/reset-password` |
+| Email Verify Notice | GET | `/email/verify` |
+| Resend Verification | POST | `/email/verification-notification` |
+| Password Confirm | POST | `/user/confirm-password` |
+| Enable 2FA | POST | `/user/two-factor-authentication` |
+| Confirm 2FA | POST | `/user/confirmed-two-factor-authentication` |
+| 2FA Challenge | POST | `/two-factor-challenge` |
+| Get QR Code | GET | `/user/two-factor-qr-code` |
+| Recovery Codes | GET/POST | `/user/two-factor-recovery-codes` |
diff --git a/.claude/skills/fluxui-development/SKILL.md b/.claude/skills/fluxui-development/SKILL.md
new file mode 100644
index 00000000..d4fb5a03
--- /dev/null
+++ b/.claude/skills/fluxui-development/SKILL.md
@@ -0,0 +1,81 @@
+---
+name: fluxui-development
+description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Flux UI Development
+
+## Documentation
+
+Use `search-docs` for detailed Flux UI patterns and documentation.
+
+## Basic Usage
+
+This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
+
+Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
+
+Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
+
+
+```blade
+Click me
+```
+
+## Available Components (Free Edition)
+
+Available: avatar, badge, brand, breadcrumbs, button, callout, card, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, pagination, profile, progress, radio, select, separator, skeleton, switch, table, text, textarea, toast, tooltip
+
+## Icons
+
+Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
+
+
+```blade
+Export
+```
+
+For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
+
+```bash
+php artisan flux:icon crown grip-vertical github
+```
+
+## Common Patterns
+
+### Form Fields
+
+
+```blade
+
+ Email
+
+
+
+```
+
+### Modals
+
+
+```blade
+
+ Title
+ Content
+
+```
+
+## Verification
+
+1. Check component renders correctly
+2. Test interactive states
+3. Verify mobile responsiveness
+
+## Common Pitfalls
+
+- Trying to use Pro-only components in the free edition
+- Not checking if a Flux component exists before creating custom implementations
+- Forgetting to use the `search-docs` tool for component-specific documentation
+- Not following existing project patterns for Flux usage
diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md
new file mode 100644
index 00000000..d136d755
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/SKILL.md
@@ -0,0 +1,59 @@
+---
+name: laravel-best-practices
+description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Laravel Best Practices
+
+Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
+
+## Consistency First
+
+Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
+
+Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
+
+## How to Apply
+
+1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
+2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
+3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
+4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
+5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
+6. Re-read the diff against every mapped rule before finishing.
+
+## Rule Index
+
+Cross-cutting changes often need more than one rule file.
+
+| Concern | Read |
+| --- | --- |
+| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
+| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
+| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
+| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
+| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
+| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
+| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
+| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
+| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
+| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
+| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
+| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
+| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
+| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
+| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
+| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
+| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
+| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
+| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
+| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
+
+## Decision Rules
+
+- Prefer framework features and existing application abstractions over new helpers or dependencies.
+- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
+- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md
new file mode 100644
index 00000000..f12876e4
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md
@@ -0,0 +1,106 @@
+# Advanced Query Patterns
+
+## Use `addSelect()` Subqueries for Single Values from Has-Many
+
+Instead of eager-loading an entire has-many relationship for a single value (like the latest timestamp), use a correlated subquery via `addSelect()`. This pulls the value directly in the main SQL query — zero extra queries.
+
+```php
+public function scopeWithLastLoginAt($query): void
+{
+ $query->addSelect([
+ 'last_login_at' => Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->withCasts(['last_login_at' => 'datetime']);
+}
+```
+
+## Create Dynamic Relationships via Subquery FK
+
+Extend the `addSelect()` pattern to fetch a foreign key via subquery, then define a `belongsTo` relationship on that virtual attribute. This provides a fully-hydrated related model without loading the entire collection.
+
+```php
+public function lastLogin(): BelongsTo
+{
+ return $this->belongsTo(Login::class);
+}
+
+public function scopeWithLastLogin($query): void
+{
+ $query->addSelect([
+ 'last_login_id' => Login::select('id')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1),
+ ])->with('lastLogin');
+}
+```
+
+## Use Conditional Aggregates Instead of Multiple Count Queries
+
+Replace N separate `count()` queries with a single query using `CASE WHEN` inside `selectRaw()`. Use `toBase()` to skip model hydration when you only need scalar values.
+
+```php
+$statuses = Feature::toBase()
+ ->selectRaw("count(case when status = 'Requested' then 1 end) as requested")
+ ->selectRaw("count(case when status = 'Planned' then 1 end) as planned")
+ ->selectRaw("count(case when status = 'Completed' then 1 end) as completed")
+ ->first();
+```
+
+## Use `setRelation()` to Prevent Circular N+1
+
+When a parent model is eager-loaded with its children, and the view also needs `$child->parent`, use `setRelation()` to inject the already-loaded parent rather than letting Eloquent fire N additional queries.
+
+```php
+$feature->load('comments.user');
+$feature->comments->each->setRelation('feature', $feature);
+```
+
+## Prefer `whereIn` + Subquery Over `whereHas`
+
+`whereHas()` emits a correlated `EXISTS` subquery that re-executes per row. Using `whereIn()` with a `select('id')` subquery lets the database use an index lookup instead, without loading data into PHP memory.
+
+Incorrect (correlated EXISTS re-executes per row):
+
+```php
+$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term));
+```
+
+Correct (index-friendly subquery, no PHP memory overhead):
+
+```php
+$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id'));
+```
+
+## Sometimes Two Simple Queries Beat One Complex Query
+
+Running a small, targeted secondary query and passing its results via `whereIn` is often faster than a single complex correlated subquery or join. The additional round-trip is worthwhile when the secondary query is highly selective and uses its own index.
+
+## Use Compound Indexes Matching `orderBy` Column Order
+
+When ordering by multiple columns, create a single compound index in the same column order as the `ORDER BY` clause. Individual single-column indexes cannot combine for multi-column sorts — the database will filesort without a compound index.
+
+```php
+// Migration
+$table->index(['last_name', 'first_name']);
+
+// Query — column order must match the index
+User::query()->orderBy('last_name')->orderBy('first_name')->paginate();
+```
+
+## Use Correlated Subqueries for Has-Many Ordering
+
+When sorting by a value from a has-many relationship, avoid joins (they duplicate rows). Use a correlated subquery inside `orderBy()` instead, paired with an `addSelect` scope for eager loading.
+
+```php
+public function scopeOrderByLastLogin($query): void
+{
+ $query->orderByDesc(Login::select('created_at')
+ ->whereColumn('user_id', 'users.id')
+ ->latest()
+ ->take(1)
+ );
+}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md
new file mode 100644
index 00000000..138d5a48
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/architecture.md
@@ -0,0 +1,202 @@
+# Architecture Best Practices
+
+## Single-Purpose Action Classes
+
+Extract discrete business operations into invokable Action classes.
+
+```php
+class CreateOrderAction
+{
+ public function __construct(private InventoryService $inventory) {}
+
+ public function handle(array $data): Order
+ {
+ $order = Order::create($data);
+ $this->inventory->reserve($order);
+
+ return $order;
+ }
+}
+```
+
+## Use Dependency Injection
+
+Always use constructor injection. Avoid `app()` or `resolve()` inside classes.
+
+Incorrect:
+```php
+class OrderController extends Controller
+{
+ public function store(StoreOrderRequest $request)
+ {
+ $service = app(OrderService::class);
+
+ return $service->create($request->validated());
+ }
+}
+```
+
+Correct:
+```php
+class OrderController extends Controller
+{
+ public function __construct(private OrderService $service) {}
+
+ public function store(StoreOrderRequest $request)
+ {
+ return $this->service->create($request->validated());
+ }
+}
+```
+
+## Code to Interfaces
+
+Depend on contracts at system boundaries (payment gateways, notification channels, external APIs) for testability and swappability.
+
+Incorrect (concrete dependency):
+```php
+class OrderService
+{
+ public function __construct(private StripeGateway $gateway) {}
+}
+```
+
+Correct (interface dependency):
+```php
+interface PaymentGateway
+{
+ public function charge(int $amount, string $customerId): PaymentResult;
+}
+
+class OrderService
+{
+ public function __construct(private PaymentGateway $gateway) {}
+}
+```
+
+Bind in a service provider:
+
+```php
+$this->app->bind(PaymentGateway::class, StripeGateway::class);
+```
+
+## Default Sort by Descending
+
+When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
+
+Incorrect:
+```php
+$posts = Post::paginate();
+```
+
+Correct:
+```php
+$posts = Post::latest()->paginate();
+```
+
+## Use Atomic Locks for Race Conditions
+
+Prevent race conditions with `Cache::lock()` or `lockForUpdate()`.
+
+```php
+Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) {
+ $order->process();
+});
+
+// Or at query level
+$product = Product::where('id', $id)->lockForUpdate()->first();
+```
+
+## Use `mb_*` String Functions
+
+When no Laravel helper exists, prefer `mb_strlen`, `mb_strtolower`, etc. for UTF-8 safety. Standard PHP string functions count bytes, not characters.
+
+Incorrect:
+```php
+strlen('José'); // 5 (bytes, not characters)
+strtolower('MÜNCHEN'); // 'mÜnchen' — fails on multibyte
+```
+
+Correct:
+```php
+mb_strlen('José'); // 4 (characters)
+mb_strtolower('MÜNCHEN'); // 'münchen'
+
+// Prefer Laravel's Str helpers when available
+Str::length('José'); // 4
+Str::lower('MÜNCHEN'); // 'münchen'
+```
+
+## Use `defer()` for Post-Response Work
+
+For lightweight tasks that don't need to survive a crash (logging, analytics, cleanup), use `defer()` instead of dispatching a job. The callback runs after the HTTP response is sent — no queue overhead.
+
+Incorrect (job overhead for trivial work):
+```php
+dispatch(new LogPageView($page));
+```
+
+Correct (runs after response, same process):
+```php
+defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()]));
+```
+
+Use jobs when the work must survive process crashes or needs retry logic. Use `defer()` for fire-and-forget work.
+
+## Use `Context` for Request-Scoped Data
+
+The `Context` facade passes data through the entire request lifecycle — middleware, controllers, jobs, logs — without passing arguments manually.
+
+```php
+// In middleware
+Context::add('tenant_id', $request->header('X-Tenant-ID'));
+
+// Anywhere later — controllers, jobs, log context
+$tenantId = Context::get('tenant_id');
+```
+
+Context data automatically propagates to queued jobs and is included in log entries. Use `Context::addHidden()` for sensitive data that should be available in queued jobs but excluded from log context. If data must not leave the current process, do not store it in `Context`.
+
+## Use `Concurrency::run()` for Parallel Execution
+
+Run independent operations in parallel using child processes — no async libraries needed.
+
+```php
+use Illuminate\Support\Facades\Concurrency;
+
+[$users, $orders] = Concurrency::run([
+ fn () => User::count(),
+ fn () => Order::where('status', 'pending')->count(),
+]);
+```
+
+Each closure runs in a separate process with full Laravel access. Use for independent database queries, API calls, or computations that would otherwise run sequentially.
+
+## Convention Over Configuration
+
+Follow Laravel conventions. Don't override defaults unnecessarily.
+
+Incorrect:
+```php
+class Customer extends Model
+{
+ protected $table = 'Customer';
+ protected $primaryKey = 'customer_id';
+
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id');
+ }
+}
+```
+
+Correct:
+```php
+class Customer extends Model
+{
+ public function roles(): BelongsToMany
+ {
+ return $this->belongsToMany(Role::class);
+ }
+}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md
new file mode 100644
index 00000000..5f0b3a1e
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/blade-views.md
@@ -0,0 +1,36 @@
+# Blade & Views Best Practices
+
+## Use `$attributes->merge()` in Component Templates
+
+Hardcoding classes prevents consumers from adding their own. `merge()` combines class attributes cleanly.
+
+```blade
+merge(['class' => 'alert alert-'.$type]) }}>
+ {{ $message }}
+
+```
+
+## Use `@pushOnce` for Per-Component Scripts
+
+If a component renders inside a `@foreach`, `@push` inserts the script N times. `@pushOnce` guarantees it's included exactly once.
+
+## Prefer Blade Components Over `@include`
+
+`@include` shares all parent variables implicitly (hidden coupling). Components have explicit props, attribute bags, and slots.
+
+## Use View Composers for Shared View Data
+
+If every controller rendering a sidebar must pass `$categories`, that's duplicated code. A View Composer centralizes it.
+
+## Use Blade Fragments for Partial Re-Renders (htmx/Turbo)
+
+A single view can return either the full page or just a fragment, keeping routing clean.
+
+```php
+return view('dashboard', compact('users'))
+ ->fragmentIf($request->hasHeader('HX-Request'), 'user-list');
+```
+
+## Use `@aware` for Deeply Nested Component Props
+
+Avoids re-passing parent props through every level of nested components.
diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md
new file mode 100644
index 00000000..67408d6e
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/caching.md
@@ -0,0 +1,70 @@
+# Caching Best Practices
+
+## Use `Cache::remember()` Instead of Manual Get/Put
+
+Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
+
+Incorrect:
+```php
+$val = Cache::get('stats');
+if (! $val) {
+ $val = $this->computeStats();
+ Cache::put('stats', $val, 60);
+}
+```
+
+Correct:
+```php
+$val = Cache::remember('stats', 60, fn () => $this->computeStats());
+```
+
+## Use `Cache::flexible()` for Stale-While-Revalidate
+
+On high-traffic keys, one user always gets a slow response when the cache expires. `flexible()` serves slightly stale data while refreshing in the background.
+
+Incorrect: `Cache::remember('users', 300, fn () => User::all());`
+
+Correct: `Cache::flexible('users', [300, 600], fn () => User::all());` — fresh for 5 min, stale-but-served up to 10 min, refreshes via deferred function.
+
+## Use `Cache::memo()` to Avoid Redundant Hits Within a Request
+
+If the same cache key is read multiple times per request (e.g., a service called from multiple places), `memo()` stores the resolved value in memory.
+
+`Cache::memo()->get('settings');` — 5 calls = 1 Redis round-trip instead of 5.
+
+## Use Cache Tags to Invalidate Related Groups
+
+Without tags, invalidating a group of entries requires tracking every key. Tags let you flush atomically. Only works with `redis`, `memcached`, `dynamodb` — not `file` or `database`.
+
+```php
+Cache::tags(['user-1'])->flush();
+```
+
+## Use `Cache::add()` for Atomic Conditional Writes
+
+`add()` only writes if the key does not exist — atomic, no race condition between checking and writing.
+
+Incorrect: `if (! Cache::has('lock')) { Cache::put('lock', true, 10); }`
+
+Correct: `Cache::add('lock', true, 10);`
+
+## Use `once()` for Per-Request Memoization
+
+`once()` memoizes a function's return value for the lifetime of the object (or request for closures). Unlike `Cache::memo()`, it doesn't hit the cache store at all — pure in-memory.
+
+```php
+public function roles(): Collection
+{
+ return once(fn () => $this->loadRoles());
+}
+```
+
+Multiple calls return the cached result without re-executing. Use `once()` for expensive computations called multiple times per request. Use `Cache::memo()` when you also want cross-request caching.
+
+## Configure Failover Cache Stores in Production
+
+If Redis goes down, the app falls back to a secondary store automatically.
+
+```php
+'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']],
+```
diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md
new file mode 100644
index 00000000..18e8d9e1
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/collections.md
@@ -0,0 +1,44 @@
+# Collection Best Practices
+
+## Use Higher-Order Messages for Simple Operations
+
+Incorrect:
+```php
+$users->each(function (User $user) {
+ $user->markAsVip();
+});
+```
+
+Correct: `$users->each->markAsVip();`
+
+Works with `each`, `map`, `sum`, `filter`, `reject`, `contains`, etc.
+
+## Choose `cursor()` vs. `lazy()` Correctly
+
+- `cursor()` — one model in memory, but cannot eager-load relationships (N+1 risk).
+- `lazy()` — chunked pagination returning a flat LazyCollection, supports eager loading.
+
+Incorrect: `User::with('roles')->cursor()` — eager loading silently ignored.
+
+Correct: `User::with('roles')->lazy()` for relationship access; `User::cursor()` for attribute-only work.
+
+## Use `lazyById()` When Updating Records While Iterating
+
+`lazy()` uses offset pagination — updating records during iteration can skip or double-process. `lazyById()` uses `id > last_id`, safe against mutation.
+
+## Use `toQuery()` for Bulk Operations on Collections
+
+Avoids manual `whereIn` construction.
+
+Incorrect: `User::whereIn('id', $users->pluck('id'))->update([...]);`
+
+Correct: `$users->toQuery()->update([...]);`
+
+## Use `#[CollectedBy]` for Custom Collection Classes
+
+More declarative than overriding `newCollection()`.
+
+```php
+#[CollectedBy(UserCollection::class)]
+class User extends Model {}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md
new file mode 100644
index 00000000..9bea727b
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/config.md
@@ -0,0 +1,73 @@
+# Configuration Best Practices
+
+## `env()` Only in Config Files
+
+Direct `env()` calls may return `null` when config is cached.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'key' => env('API_KEY'),
+
+// Application code
+$key = config('services.key');
+```
+
+## Use Encrypted Env or External Secrets
+
+Never store production secrets in plain `.env` files in version control.
+
+Incorrect:
+```bash
+
+# .env committed to repo or shared in Slack
+
+STRIPE_SECRET=sk_live_abc123
+AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI
+```
+
+Correct:
+```bash
+php artisan env:encrypt --env=production --readable
+php artisan env:decrypt --env=production
+```
+
+For cloud deployments, prefer the platform's native secret store (AWS Secrets Manager, Vault, etc.) and inject at runtime.
+
+## Use `App::environment()` for Environment Checks
+
+Incorrect:
+```php
+if (env('APP_ENV') === 'production') {
+```
+
+Correct:
+```php
+if (app()->isProduction()) {
+// or
+if (App::environment('production')) {
+```
+
+## Use Constants and Language Files
+
+Use class constants instead of hardcoded magic strings for model states, types, and statuses.
+
+```php
+// Incorrect
+return $this->type === 'normal';
+
+// Correct
+return $this->type === self::TYPE_NORMAL;
+```
+
+If the application already uses language files for localization, use `__()` for user-facing strings too. Do not introduce language files purely for English-only apps — simple string literals are fine there.
+
+```php
+// Only when lang files already exist in the project
+return back()->with('message', __('app.article_added'));
+```
diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md
new file mode 100644
index 00000000..c49ba164
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/db-performance.md
@@ -0,0 +1,192 @@
+# Database Performance Best Practices
+
+## Always Eager Load Relationships
+
+Lazy loading causes N+1 query problems — one query per loop iteration. Always use `with()` to load relationships upfront.
+
+Incorrect (N+1 — executes 1 + N queries):
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Correct (2 queries total):
+```php
+$posts = Post::with('author')->get();
+foreach ($posts as $post) {
+ echo $post->author->name;
+}
+```
+
+Constrain eager loads to select only needed columns (always include the foreign key):
+
+```php
+$users = User::with(['posts' => function ($query) {
+ $query->select('id', 'user_id', 'title')
+ ->where('published', true)
+ ->latest()
+ ->limit(10);
+}])->get();
+```
+
+## Prevent Lazy Loading in Development
+
+Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development.
+
+```php
+public function boot(): void
+{
+ Model::preventLazyLoading(! app()->isProduction());
+}
+```
+
+Throws `LazyLoadingViolationException` when a relationship is accessed without being eager-loaded.
+
+## Select Only Needed Columns
+
+Avoid `SELECT *` — especially when tables have large text or JSON columns.
+
+Incorrect:
+```php
+$posts = Post::with('author')->get();
+```
+
+Correct:
+```php
+$posts = Post::select('id', 'title', 'user_id', 'created_at')
+ ->with(['author:id,name,avatar'])
+ ->get();
+```
+
+When selecting columns on eager-loaded relationships, always include the foreign key column or the relationship won't match.
+
+## Chunk Large Datasets
+
+Never load thousands of records at once. Use chunking for batch processing.
+
+Incorrect:
+```php
+$users = User::all();
+foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+}
+```
+
+Correct:
+```php
+User::where('subscribed', true)->chunk(200, function ($users) {
+ foreach ($users as $user) {
+ $user->notify(new WeeklyDigest);
+ }
+});
+```
+
+Use `chunkById()` when modifying records during iteration — standard `chunk()` uses OFFSET which shifts when rows change:
+
+```php
+User::where('active', false)->chunkById(200, function ($users) {
+ $users->each->delete();
+});
+```
+
+## Add Database Indexes
+
+Index columns that appear in `WHERE`, `ORDER BY`, `JOIN`, and `GROUP BY` clauses.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->index()->constrained();
+ $table->string('status')->index();
+ $table->timestamps();
+ $table->index(['status', 'created_at']);
+});
+```
+
+Add composite indexes for common query patterns (e.g., `WHERE status = ? ORDER BY created_at`).
+
+## Use `withCount()` for Counting Relations
+
+Never load entire collections just to count them.
+
+Incorrect:
+```php
+$posts = Post::all();
+foreach ($posts as $post) {
+ echo $post->comments->count();
+}
+```
+
+Correct:
+```php
+$posts = Post::withCount('comments')->get();
+foreach ($posts as $post) {
+ echo $post->comments_count;
+}
+```
+
+Conditional counting:
+
+```php
+$posts = Post::withCount([
+ 'comments',
+ 'comments as approved_comments_count' => function ($query) {
+ $query->where('approved', true);
+ },
+])->get();
+```
+
+## Use `cursor()` for Memory-Efficient Iteration
+
+For read-only iteration over large result sets, `cursor()` loads one record at a time via a PHP generator.
+
+Incorrect:
+```php
+$users = User::where('active', true)->get();
+```
+
+Correct:
+```php
+foreach (User::where('active', true)->cursor() as $user) {
+ ProcessUser::dispatch($user->id);
+}
+```
+
+Use `cursor()` for read-only iteration. Use `chunk()` / `chunkById()` when modifying records.
+
+## No Queries in Blade Templates
+
+Never execute queries in Blade templates. Pass data from controllers.
+
+Incorrect:
+```blade
+@foreach (User::all() as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
+
+Correct:
+```php
+// Controller
+$users = User::with('profile')->get();
+return view('users.index', compact('users'));
+```
+
+```blade
+@foreach ($users as $user)
+ {{ $user->profile->name }}
+@endforeach
+```
diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md
new file mode 100644
index 00000000..413d5da4
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/eloquent.md
@@ -0,0 +1,148 @@
+# Eloquent Best Practices
+
+## Use Correct Relationship Types
+
+Use `hasMany`, `belongsTo`, `morphMany`, etc. with proper return type hints.
+
+```php
+public function comments(): HasMany
+{
+ return $this->hasMany(Comment::class);
+}
+
+public function author(): BelongsTo
+{
+ return $this->belongsTo(User::class, 'user_id');
+}
+```
+
+## Use Local Scopes for Reusable Queries
+
+Extract reusable query constraints into local scopes to avoid duplication.
+
+Incorrect:
+```php
+$active = User::where('verified', true)->whereNotNull('activated_at')->get();
+$articles = Article::whereHas('user', function ($q) {
+ $q->where('verified', true)->whereNotNull('activated_at');
+})->get();
+```
+
+Correct:
+```php
+public function scopeActive(Builder $query): Builder
+{
+ return $query->where('verified', true)->whereNotNull('activated_at');
+}
+
+// Usage
+$active = User::active()->get();
+$articles = Article::whereHas('user', fn ($q) => $q->active())->get();
+```
+
+## Apply Global Scopes Sparingly
+
+Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy.
+
+Incorrect (global scope for a conditional filter):
+```php
+class PublishedScope implements Scope
+{
+ public function apply(Builder $builder, Model $model): void
+ {
+ $builder->where('published', true);
+ }
+}
+// Now admin panels, reports, and background jobs all silently skip drafts
+```
+
+Correct (local scope you opt into):
+```php
+public function scopePublished(Builder $query): Builder
+{
+ return $query->where('published', true);
+}
+
+Post::published()->paginate(); // Explicit
+Post::paginate(); // Admin sees all
+```
+
+## Define Attribute Casts
+
+Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion.
+
+```php
+protected function casts(): array
+{
+ return [
+ 'is_active' => 'boolean',
+ 'metadata' => 'array',
+ 'total' => 'decimal:2',
+ ];
+}
+```
+
+## Cast Date Columns Properly
+
+Always cast date columns. Use Carbon instances in templates instead of formatting strings manually.
+
+Incorrect:
+```blade
+{{ Carbon::createFromFormat('Y-d-m H-i', $order->ordered_at)->toDateString() }}
+```
+
+Correct:
+```php
+protected function casts(): array
+{
+ return [
+ 'ordered_at' => 'datetime',
+ ];
+}
+```
+
+```blade
+{{ $order->ordered_at->toDateString() }}
+{{ $order->ordered_at->format('m-d') }}
+```
+
+## Use `whereBelongsTo()` for Relationship Queries
+
+Cleaner than manually specifying foreign keys.
+
+Incorrect:
+```php
+Post::where('user_id', $user->id)->get();
+```
+
+Correct:
+```php
+Post::whereBelongsTo($user)->get();
+Post::whereBelongsTo($user, 'author')->get();
+```
+
+## Avoid Hardcoded Table Names in Queries
+
+Never use string literals for table names in raw queries, joins, or subqueries. Hardcoded table names make it impossible to find all places a model is used and break refactoring (e.g., renaming a table requires hunting through every raw string).
+
+Incorrect:
+```php
+DB::table('users')->where('active', true)->get();
+
+$query->join('companies', 'companies.id', '=', 'users.company_id');
+
+DB::select('SELECT * FROM orders WHERE status = ?', ['pending']);
+```
+
+Correct — reference the model's table:
+```php
+DB::table((new User)->getTable())->where('active', true)->get();
+
+// Even better — use Eloquent or the query builder instead of raw SQL
+User::where('active', true)->get();
+Order::where('status', 'pending')->get();
+```
+
+Prefer Eloquent queries and relationships over `DB::table()` whenever possible — they already reference the model's table. When `DB::table()` or raw joins are unavoidable, always use `(new Model)->getTable()` to keep the reference traceable.
+
+**Exception — migrations:** In migrations, hardcoded table names via `DB::table('settings')` are acceptable and preferred. Models change over time but migrations are frozen snapshots — referencing a model that is later renamed or deleted would break the migration.
diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md
new file mode 100644
index 00000000..4b148667
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/error-handling.md
@@ -0,0 +1,72 @@
+# Error Handling Best Practices
+
+## Exception Reporting and Rendering
+
+There are two valid approaches — choose one and apply it consistently across the project.
+
+**Co-location on the exception class** — keeps behavior alongside the exception definition, easier to find:
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function report(): void { /* custom reporting */ }
+
+ public function render(Request $request): Response
+ {
+ return response()->view('errors.invalid-order', status: 422);
+ }
+}
+```
+
+**Centralized in `bootstrap/app.php`** — all exception handling in one place, easier to see the full picture:
+
+```php
+->withExceptions(function (Exceptions $exceptions) {
+ $exceptions->report(function (InvalidOrderException $e) { /* ... */ });
+ $exceptions->render(function (InvalidOrderException $e, Request $request) {
+ return response()->view('errors.invalid-order', status: 422);
+ });
+})
+```
+
+Check the existing codebase and follow whichever pattern is already established.
+
+## Use `ShouldntReport` for Exceptions That Should Never Log
+
+More discoverable than listing classes in `dontReport()`.
+
+```php
+class PodcastProcessingException extends Exception implements ShouldntReport {}
+```
+
+## Throttle High-Volume Exceptions
+
+A single failing integration can flood error tracking. Use `throttle()` to rate-limit per exception type.
+
+## Enable `dontReportDuplicates()`
+
+Prevents the same exception instance from being logged multiple times when `report($e)` is called in multiple catch blocks.
+
+## Force JSON Error Rendering for API Routes
+
+Laravel auto-detects `Accept: application/json` but API clients may not set it. Explicitly declare JSON rendering for API routes.
+
+```php
+$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) {
+ return $request->is('api/*') || $request->expectsJson();
+});
+```
+
+## Add Context to Exception Classes
+
+Attach structured data to exceptions at the source via a `context()` method — Laravel includes it automatically in the log entry.
+
+```php
+class InvalidOrderException extends Exception
+{
+ public function context(): array
+ {
+ return ['order_id' => $this->orderId];
+ }
+}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md
new file mode 100644
index 00000000..82e329e8
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md
@@ -0,0 +1,52 @@
+# Events & Notifications Best Practices
+
+## Rely on Event Discovery
+
+Laravel auto-discovers listeners by reading `handle(EventType $event)` type-hints. No manual registration needed in `AppServiceProvider`.
+
+## Run `event:cache` in Production Deploy
+
+Event discovery scans the filesystem per-request in dev. Cache it in production: `php artisan optimize` or `php artisan event:cache`.
+
+## Use `ShouldDispatchAfterCommit` Inside Transactions
+
+Without it, a queued listener may process before the DB transaction commits, reading data that doesn't exist yet.
+
+```php
+class OrderShipped implements ShouldDispatchAfterCommit {}
+```
+
+## Always Queue Notifications
+
+Notifications often hit external APIs (email, SMS, Slack). Without `ShouldQueue`, they block the HTTP response.
+
+```php
+class InvoicePaid extends Notification implements ShouldQueue
+{
+ use Queueable;
+}
+```
+
+## Use `afterCommit()` on Notifications in Transactions
+
+Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
+
+```php
+$user->notify((new InvoicePaid($invoice))->afterCommit());
+```
+
+## Route Notification Channels to Dedicated Queues
+
+Mail and database notifications have different priorities. Use `viaQueues()` to route them to separate queues.
+
+## Use On-Demand Notifications for Non-User Recipients
+
+Avoid creating dummy models to send notifications to arbitrary addresses.
+
+```php
+Notification::route('mail', 'admin@example.com')->notify(new SystemAlert());
+```
+
+## Implement `HasLocalePreference` on Notifiable Models
+
+Laravel automatically uses the user's preferred locale for all notifications and mailables — no per-call `locale()` needed.
diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md
new file mode 100644
index 00000000..8e2f16e8
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/http-client.md
@@ -0,0 +1,160 @@
+# HTTP Client Best Practices
+
+## Always Set Explicit Timeouts
+
+The default timeout is 30 seconds — too long for most API calls. Always set explicit `timeout` and `connectTimeout` to fail fast.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users');
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->connectTimeout(3)
+ ->get('https://api.example.com/users');
+```
+
+For service-specific clients, define timeouts in a macro:
+
+```php
+Http::macro('github', function () {
+ return Http::baseUrl('https://api.github.com')
+ ->timeout(10)
+ ->connectTimeout(3)
+ ->withToken(config('services.github.token'));
+});
+
+$response = Http::github()->get('/repos/laravel/framework');
+```
+
+## Use Retry with Backoff for External APIs
+
+External APIs have transient failures. Use `retry()` with increasing delays.
+
+Incorrect:
+```php
+$response = Http::post('https://api.stripe.com/v1/charges', $data);
+
+if ($response->failed()) {
+ throw new PaymentFailedException('Charge failed');
+}
+```
+
+Correct:
+```php
+$response = Http::retry([100, 500, 1000])
+ ->timeout(10)
+ ->post('https://api.stripe.com/v1/charges', $data);
+```
+
+Only retry on specific errors:
+
+```php
+$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
+ return $exception instanceof ConnectionException
+ || ($exception instanceof RequestException && $exception->response->serverError());
+})->post('https://api.example.com/data');
+```
+
+## Handle Errors Explicitly
+
+The HTTP Client does not throw on 4xx/5xx by default. Always check status or use `throw()`.
+
+Incorrect:
+```php
+$response = Http::get('https://api.example.com/users/1');
+$user = $response->json(); // Could be an error body
+```
+
+Correct:
+```php
+$response = Http::timeout(5)
+ ->get('https://api.example.com/users/1')
+ ->throw();
+
+$user = $response->json();
+```
+
+For graceful degradation:
+
+```php
+$response = Http::get('https://api.example.com/users/1');
+
+if ($response->successful()) {
+ return $response->json();
+}
+
+if ($response->notFound()) {
+ return null;
+}
+
+$response->throw();
+```
+
+## Use Request Pooling for Concurrent Requests
+
+When making multiple independent API calls, use `Http::pool()` instead of sequential calls.
+
+Incorrect:
+```php
+$users = Http::get('https://api.example.com/users')->json();
+$posts = Http::get('https://api.example.com/posts')->json();
+$comments = Http::get('https://api.example.com/comments')->json();
+```
+
+Correct:
+```php
+use Illuminate\Http\Client\Pool;
+
+$responses = Http::pool(fn (Pool $pool) => [
+ $pool->as('users')->get('https://api.example.com/users'),
+ $pool->as('posts')->get('https://api.example.com/posts'),
+ $pool->as('comments')->get('https://api.example.com/comments'),
+]);
+
+$users = $responses['users']->json();
+$posts = $responses['posts']->json();
+```
+
+## Fake HTTP Calls in Tests
+
+Never make real HTTP requests in tests. Use `Http::fake()` and `preventStrayRequests()`.
+
+Incorrect:
+```php
+it('syncs user from API', function () {
+ $service = new UserSyncService;
+ $service->sync(1); // Hits the real API
+});
+```
+
+Correct:
+```php
+it('syncs user from API', function () {
+ Http::preventStrayRequests();
+
+ Http::fake([
+ 'api.example.com/users/1' => Http::response([
+ 'name' => 'John Doe',
+ 'email' => 'john@example.com',
+ ]),
+ ]);
+
+ $service = new UserSyncService;
+ $service->sync(1);
+
+ Http::assertSent(function (Request $request) {
+ return $request->url() === 'https://api.example.com/users/1';
+ });
+});
+```
+
+Test failure scenarios too:
+
+```php
+Http::fake([
+ 'api.example.com/*' => Http::failedConnection(),
+]);
+```
diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md
new file mode 100644
index 00000000..7c717336
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/mail.md
@@ -0,0 +1,27 @@
+# Mail Best Practices
+
+## Implement `ShouldQueue` on the Mailable Class
+
+Makes queueing the default regardless of how the mailable is dispatched. No need to remember `Mail::queue()` at every call site — `Mail::send()` also queues it.
+
+## Use `afterCommit()` on Mailables Inside Transactions
+
+A queued mailable dispatched inside a transaction may process before the commit. Use `$this->afterCommit()` in the constructor.
+
+## Use `assertQueued()` Not `assertSent()` for Queued Mailables
+
+`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
+
+Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
+
+Correct: `Mail::assertQueued(OrderShipped::class);`
+
+## Use Markdown Mailables for Transactional Emails
+
+Markdown mailables auto-generate both HTML and plain-text versions, use responsive components, and allow global style customization. Generate with `--markdown` flag.
+
+## Separate Content Tests from Sending Tests
+
+Content tests: instantiate the mailable directly, call `assertSeeInHtml()`.
+Sending tests: use `Mail::fake()` and `assertSent()`/`assertQueued()`.
+Don't mix them — it conflates concerns and makes tests brittle.
diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md
new file mode 100644
index 00000000..df6f5f33
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/migrations.md
@@ -0,0 +1,121 @@
+# Migration Best Practices
+
+## Generate Migrations with Artisan
+
+Always use `php artisan make:migration` for consistent naming and timestamps.
+
+Incorrect (manually created file):
+```php
+// database/migrations/posts_migration.php ← wrong naming, no timestamp
+```
+
+Correct (Artisan-generated):
+```bash
+php artisan make:migration create_posts_table
+php artisan make:migration add_slug_to_posts_table
+```
+
+## Use `constrained()` for Foreign Keys
+
+Automatic naming and referential integrity.
+
+```php
+$table->foreignId('user_id')->constrained()->cascadeOnDelete();
+
+// Non-standard names
+$table->foreignId('author_id')->constrained('users');
+```
+
+## Never Modify Deployed Migrations
+
+Once a migration has run in production, treat it as immutable. Create a new migration to change the table.
+
+Incorrect (editing a deployed migration):
+```php
+// 2024_01_01_create_posts_table.php — already in production
+$table->string('slug')->unique(); // ← added after deployment
+```
+
+Correct (new migration to alter):
+```php
+// 2024_03_15_add_slug_to_posts_table.php
+Schema::table('posts', function (Blueprint $table) {
+ $table->string('slug')->unique()->after('title');
+});
+```
+
+## Add Indexes in the Migration
+
+Add indexes when creating the table, not as an afterthought. Columns used in `WHERE`, `ORDER BY`, and `JOIN` clauses need indexes.
+
+Incorrect:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained();
+ $table->string('status');
+ $table->timestamps();
+});
+```
+
+Correct:
+```php
+Schema::create('orders', function (Blueprint $table) {
+ $table->id();
+ $table->foreignId('user_id')->constrained()->index();
+ $table->string('status')->index();
+ $table->timestamp('shipped_at')->nullable()->index();
+ $table->timestamps();
+});
+```
+
+## Mirror Defaults in Model `$attributes`
+
+When a column has a database default, mirror it in the model so new instances have correct values before saving.
+
+```php
+// Migration
+$table->string('status')->default('pending');
+
+// Model
+protected $attributes = [
+ 'status' => 'pending',
+];
+```
+
+## Write Reversible `down()` Methods by Default
+
+Implement `down()` for schema changes that can be safely reversed so `migrate:rollback` works in CI and failed deployments.
+
+```php
+public function down(): void
+{
+ Schema::table('posts', function (Blueprint $table) {
+ $table->dropColumn('slug');
+ });
+}
+```
+
+For intentionally irreversible migrations (e.g., destructive data backfills), leave a clear comment and require a forward fix migration instead of pretending rollback is supported.
+
+## Keep Migrations Focused
+
+One concern per migration. Never mix DDL (schema changes) and DML (data manipulation).
+
+Incorrect (partial failure creates unrecoverable state):
+```php
+public function up(): void
+{
+ Schema::create('settings', function (Blueprint $table) { ... });
+ DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+}
+```
+
+Correct (separate migrations):
+```php
+// Migration 1: create_settings_table
+Schema::create('settings', function (Blueprint $table) { ... });
+
+// Migration 2: seed_default_settings
+DB::table('settings')->insert(['key' => 'version', 'value' => '1.0']);
+```
diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md
new file mode 100644
index 00000000..c41915e2
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md
@@ -0,0 +1,144 @@
+# Queue & Job Best Practices
+
+## Set `retry_after` Greater Than `timeout`
+
+If `retry_after` is shorter than the job's `timeout`, the queue worker re-dispatches the job while it's still running, causing duplicate execution.
+
+Incorrect (`retry_after` ≤ `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 90 ← job retried while still running!
+```
+
+Correct (`retry_after` > `timeout`):
+```php
+class ProcessReport implements ShouldQueue
+{
+ public $timeout = 120;
+}
+
+// config/queue.php — retry_after: 180 ← safely longer than any job timeout
+```
+
+## Use Exponential Backoff
+
+Use progressively longer delays between retries to avoid hammering failing services.
+
+Incorrect (fixed retry interval):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ // Default: retries immediately, overwhelming the API
+}
+```
+
+Correct (exponential backoff):
+```php
+class SyncWithStripe implements ShouldQueue
+{
+ public $tries = 3;
+ public $backoff = [1, 5, 10];
+}
+```
+
+## Implement `ShouldBeUnique`
+
+Prevent duplicate job processing.
+
+```php
+class GenerateInvoice implements ShouldQueue, ShouldBeUnique
+{
+ public function uniqueId(): string
+ {
+ return $this->order->id;
+ }
+
+ public $uniqueFor = 3600;
+}
+```
+
+## Always Implement `failed()`
+
+Handle errors explicitly — don't rely on silent failure.
+
+```php
+public function failed(?Throwable $exception): void
+{
+ $this->podcast->update(['status' => 'failed']);
+ Log::error('Processing failed', ['id' => $this->podcast->id, 'error' => $exception->getMessage()]);
+}
+```
+
+## Rate Limit External API Calls in Jobs
+
+Use `RateLimited` middleware to throttle jobs calling third-party APIs.
+
+```php
+public function middleware(): array
+{
+ return [new RateLimited('external-api')];
+}
+```
+
+## Batch Related Jobs
+
+Use `Bus::batch()` when jobs should succeed or fail together.
+
+```php
+Bus::batch([
+ new ImportCsvChunk($chunk1),
+ new ImportCsvChunk($chunk2),
+])
+->then(fn (Batch $batch) => Notification::send($user, new ImportComplete))
+->catch(fn (Batch $batch, Throwable $e) => Log::error('Batch failed'))
+->dispatch();
+```
+
+## `retryUntil()` Needs `$tries = 0`
+
+When using time-based retry limits, set `$tries = 0` to avoid premature failure.
+
+```php
+public $tries = 0;
+
+public function retryUntil(): \DateTimeInterface
+{
+ return now()->addHours(4);
+}
+```
+
+## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
+
+`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
+
+```php
+class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
+{
+ // Lock releases when processing begins, not when it finishes
+}
+```
+
+## Use Horizon for Complex Queue Scenarios
+
+Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
+
+```php
+// config/horizon.php
+'environments' => [
+ 'production' => [
+ 'supervisor-1' => [
+ 'connection' => 'redis',
+ 'queue' => ['high', 'default', 'low'],
+ 'balance' => 'auto',
+ 'minProcesses' => 1,
+ 'maxProcesses' => 10,
+ 'tries' => 3,
+ ],
+ ],
+],
+```
diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md
new file mode 100644
index 00000000..b6e30864
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/routing.md
@@ -0,0 +1,99 @@
+# Routing & Controllers Best Practices
+
+## Use Implicit Route Model Binding
+
+Let Laravel resolve models automatically from route parameters.
+
+Incorrect:
+```php
+public function show(int $id)
+{
+ $post = Post::findOrFail($id);
+}
+```
+
+Correct:
+```php
+public function show(Post $post)
+{
+ return view('posts.show', ['post' => $post]);
+}
+```
+
+## Use Scoped Bindings for Nested Resources
+
+Enforce parent-child relationships automatically.
+
+```php
+Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) {
+ // $post is automatically scoped to $user
+})->scopeBindings();
+```
+
+## Use Resource Controllers
+
+Use `Route::resource()` or `apiResource()` for RESTful endpoints.
+
+```php
+Route::resource('posts', PostController::class);
+// In routes/api.php — the /api prefix is applied automatically
+Route::apiResource('posts', Api\PostController::class);
+```
+
+## Keep Controllers Thin
+
+Aim for under 10 lines per method. Extract business logic to action or service classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $validated = $request->validate([...]);
+ if ($request->hasFile('image')) {
+ $request->file('image')->move(public_path('images'));
+ }
+ $post = Post::create($validated);
+ $post->tags()->sync($validated['tags']);
+ event(new PostCreated($post));
+ return redirect()->route('posts.show', $post);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request, CreatePostAction $create)
+{
+ $post = $create->execute($request->validated());
+
+ return redirect()->route('posts.show', $post);
+}
+```
+
+## Type-Hint Form Requests
+
+Type-hinting Form Requests triggers automatic validation and authorization before the method executes.
+
+Incorrect:
+```php
+public function store(Request $request): RedirectResponse
+{
+ $validated = $request->validate([
+ 'title' => ['required', 'max:255'],
+ 'body' => ['required'],
+ ]);
+
+ Post::create($validated);
+
+ return redirect()->route('posts.index');
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request): RedirectResponse
+{
+ Post::create($request->validated());
+
+ return redirect()->route('posts.index');
+}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md
new file mode 100644
index 00000000..a9847945
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/scheduling.md
@@ -0,0 +1,39 @@
+# Task Scheduling Best Practices
+
+## Use `withoutOverlapping()` on Variable-Duration Tasks
+
+Without it, a long-running task spawns a second instance on the next tick, causing double-processing or resource exhaustion.
+
+## Use `onOneServer()` on Multi-Server Deployments
+
+Without it, every server runs the same task simultaneously. Requires a shared cache driver (Redis, database, Memcached).
+
+## Use `runInBackground()` for Concurrent Long Tasks
+
+By default, tasks at the same tick run sequentially. A slow first task delays all subsequent ones. `runInBackground()` runs them as separate processes.
+
+## Use `environments()` to Restrict Tasks
+
+Prevent accidental execution of production-only tasks (billing, reporting) on staging.
+
+```php
+Schedule::command('billing:charge')->monthly()->environments(['production']);
+```
+
+## Use `takeUntilTimeout()` for Time-Bounded Processing
+
+A task running every 15 minutes that processes an unbounded cursor can overlap with the next run. Bound execution time.
+
+## Use Schedule Groups for Shared Configuration
+
+Avoid repeating `->onOneServer()->timezone('America/New_York')` across many tasks.
+
+```php
+Schedule::daily()
+ ->onOneServer()
+ ->timezone('America/New_York')
+ ->group(function () {
+ Schedule::command('emails:send --force');
+ Schedule::command('emails:prune');
+ });
+```
diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md
new file mode 100644
index 00000000..2d7200c2
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/security.md
@@ -0,0 +1,198 @@
+# Security Best Practices
+
+## Mass Assignment Protection
+
+Every model must define `$fillable` (whitelist) or `$guarded` (blacklist).
+
+Incorrect:
+```php
+class User extends Model
+{
+ protected $guarded = []; // All fields are mass assignable
+}
+```
+
+Correct:
+```php
+class User extends Model
+{
+ protected $fillable = [
+ 'name',
+ 'email',
+ 'password',
+ ];
+}
+```
+
+Never use `$guarded = []` on models that accept user input.
+
+## Authorize Every Action
+
+Use policies or gates in controllers. Never skip authorization.
+
+Incorrect:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ $post->update($request->validated());
+}
+```
+
+Correct:
+```php
+public function update(UpdatePostRequest $request, Post $post)
+{
+ Gate::authorize('update', $post);
+
+ $post->update($request->validated());
+}
+```
+
+Or via Form Request:
+
+```php
+public function authorize(): bool
+{
+ return $this->user()->can('update', $this->route('post'));
+}
+```
+
+## Prevent SQL Injection
+
+Always use parameter binding. Never interpolate user input into queries.
+
+Incorrect:
+```php
+DB::select("SELECT * FROM users WHERE name = '{$request->name}'");
+```
+
+Correct:
+```php
+User::where('name', $request->name)->get();
+
+// Raw expressions with bindings
+User::whereRaw('LOWER(name) = ?', [strtolower($request->name)])->get();
+```
+
+## Escape Output to Prevent XSS
+
+Use `{{ }}` for HTML escaping. Only use `{!! !!}` for trusted, pre-sanitized content.
+
+Incorrect:
+```blade
+{!! $user->bio !!}
+```
+
+Correct:
+```blade
+{{ $user->bio }}
+```
+
+## CSRF Protection
+
+Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
+
+Incorrect:
+```blade
+
+```
+
+Correct:
+```blade
+
+```
+
+## Rate Limit Auth and API Routes
+
+Apply `throttle` middleware to authentication and API routes.
+
+```php
+RateLimiter::for('login', function (Request $request) {
+ return Limit::perMinute(5)->by($request->ip());
+});
+
+Route::post('/login', LoginController::class)->middleware('throttle:login');
+```
+
+## Validate File Uploads
+
+Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
+
+```php
+public function rules(): array
+{
+ return [
+ 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'],
+ ];
+}
+```
+
+Store with generated filenames:
+
+```php
+$path = $request->file('avatar')->store('avatars', 'public');
+```
+
+## Keep Secrets Out of Code
+
+Never commit `.env`. Access secrets via `config()` only.
+
+Incorrect:
+```php
+$key = env('API_KEY');
+```
+
+Correct:
+```php
+// config/services.php
+'api_key' => env('API_KEY'),
+
+// In application code
+$key = config('services.api_key');
+```
+
+## Audit Dependencies
+
+Run `composer audit` periodically to check for known vulnerabilities in dependencies. Automate this in CI to catch issues before deployment.
+
+```bash
+composer audit
+```
+
+## Encrypt Sensitive Database Fields
+
+Use `encrypted` cast for API keys/tokens and mark the attribute as `hidden`.
+
+Incorrect:
+```php
+class Integration extends Model
+{
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'string',
+ ];
+ }
+}
+```
+
+Correct:
+```php
+class Integration extends Model
+{
+ protected $hidden = ['api_key', 'api_secret'];
+
+ protected function casts(): array
+ {
+ return [
+ 'api_key' => 'encrypted',
+ 'api_secret' => 'encrypted',
+ ];
+ }
+}
+```
diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md
new file mode 100644
index 00000000..a8afb369
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/style.md
@@ -0,0 +1,125 @@
+# Conventions & Style
+
+## Follow Laravel Naming Conventions
+
+| What | Convention | Good | Bad |
+|------|-----------|------|-----|
+| Controller | singular | `ArticleController` | `ArticlesController` |
+| Model | singular | `User` | `Users` |
+| Table | plural, snake_case | `article_comments` | `articleComments` |
+| Pivot table | singular alphabetical | `article_user` | `user_article` |
+| Column | snake_case, no model name | `meta_title` | `article_meta_title` |
+| Foreign key | singular model + `_id` | `article_id` | `articles_id` |
+| Route | plural | `articles/1` | `article/1` |
+| Route name | snake_case with dots | `users.show_active` | `users.show-active` |
+| Method | camelCase | `getAll` | `get_all` |
+| Variable | camelCase | `$articlesWithAuthor` | `$articles_with_author` |
+| Collection | descriptive, plural | `$activeUsers` | `$data` |
+| Object | descriptive, singular | `$activeUser` | `$users` |
+| View | kebab-case | `show-filtered.blade.php` | `showFiltered.blade.php` |
+| Config | snake_case | `google_calendar.php` | `googleCalendar.php` |
+| Enum | singular | `UserType` | `UserTypes` |
+
+## Prefer Shorter Readable Syntax
+
+| Verbose | Shorter |
+|---------|---------|
+| `Session::get('cart')` | `session('cart')` |
+| `$request->session()->get('cart')` | `session('cart')` |
+| `$request->input('name')` | `$request->name` |
+| `return Redirect::back()` | `return back()` |
+| `Carbon::now()` | `now()` |
+| `App::make('Class')` | `app('Class')` |
+| `->where('column', '=', 1)` | `->where('column', 1)` |
+| `->orderBy('created_at', 'desc')` | `->latest()` |
+| `->orderBy('created_at', 'asc')` | `->oldest()` |
+| `->first()->name` | `->value('name')` |
+
+## Use Laravel String & Array Helpers
+
+Laravel provides `Str`, `Arr`, `Number`, and `Uri` helper classes that are more readable, chainable, and UTF-8 safe than raw PHP functions. Always prefer them.
+
+Strings — use `Str` and fluent `Str::of()` over raw PHP:
+```php
+// Incorrect
+$slug = strtolower(str_replace(' ', '-', $title));
+$short = substr($text, 0, 100) . '...';
+$class = substr(strrchr('App\Models\User', '\\'), 1);
+
+// Correct
+$slug = Str::slug($title);
+$short = Str::limit($text, 100);
+$class = class_basename('App\Models\User');
+```
+
+Fluent strings — chain operations for complex transformations:
+```php
+// Incorrect
+$result = strtolower(trim(str_replace('_', '-', $input)));
+
+// Correct
+$result = Str::of($input)->trim()->replace('_', '-')->lower();
+```
+
+Key `Str` methods to prefer: `Str::slug()`, `Str::limit()`, `Str::contains()`, `Str::before()`, `Str::after()`, `Str::between()`, `Str::camel()`, `Str::snake()`, `Str::kebab()`, `Str::headline()`, `Str::squish()`, `Str::mask()`, `Str::uuid()`, `Str::ulid()`, `Str::random()`, `Str::is()`.
+
+Arrays — use `Arr` over raw PHP:
+```php
+// Incorrect
+$name = isset($array['user']['name']) ? $array['user']['name'] : 'default';
+
+// Correct
+$name = Arr::get($array, 'user.name', 'default');
+```
+
+Key `Arr` methods: `Arr::get()`, `Arr::has()`, `Arr::only()`, `Arr::except()`, `Arr::first()`, `Arr::flatten()`, `Arr::pluck()`, `Arr::where()`, `Arr::wrap()`.
+
+Numbers — use `Number` for display formatting:
+```php
+Number::format(1000000); // "1,000,000"
+Number::currency(1500, 'USD'); // "$1,500.00"
+Number::abbreviate(1000000); // "1M"
+Number::fileSize(1024 * 1024); // "1 MB"
+Number::percentage(75.5); // "75.5%"
+```
+
+URIs — use `Uri` for URL manipulation:
+```php
+$uri = Uri::of('https://example.com/search')
+ ->withQuery(['q' => 'laravel', 'page' => 1]);
+```
+
+Use `$request->string('name')` to get a fluent `Stringable` directly from request input for immediate chaining.
+
+Use `search-docs` for the full list of available methods — these helpers are extensive.
+
+## No Inline JS/CSS in Blade
+
+Do not put JS or CSS in Blade templates. Do not put HTML in PHP classes.
+
+Incorrect:
+```blade
+let article = `{{ json_encode($article) }}`;
+```
+
+Correct:
+```blade
+{{ $article->name }}
+```
+
+Pass data to JS via data attributes or use a dedicated PHP-to-JS package.
+
+## No Unnecessary Comments
+
+Code should be readable on its own. Use descriptive method and variable names instead of comments. The only exception is config files, where descriptive comments are expected.
+
+Incorrect:
+```php
+// Check if there are any joins
+if (count((array) $builder->getQuery()->joins) > 0)
+```
+
+Correct:
+```php
+if ($this->hasJoins())
+```
diff --git a/.claude/skills/laravel-best-practices/rules/testing.md b/.claude/skills/laravel-best-practices/rules/testing.md
new file mode 100644
index 00000000..4fbf12f8
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/testing.md
@@ -0,0 +1,43 @@
+# Testing Best Practices
+
+## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
+
+`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
+
+## Use Model Assertions Over Raw Database Assertions
+
+Incorrect: `$this->assertDatabaseHas('users', ['id' => $user->id]);`
+
+Correct: `$this->assertModelExists($user);`
+
+More expressive, type-safe, and fails with clearer messages.
+
+## Use Factory States and Sequences
+
+Named states make tests self-documenting. Sequences eliminate repetitive setup.
+
+Incorrect: `User::factory()->create(['email_verified_at' => null]);`
+
+Correct: `User::factory()->unverified()->create();`
+
+## Use `Exceptions::fake()` to Assert Exception Reporting
+
+Instead of `withoutExceptionHandling()`, use `Exceptions::fake()` to assert the correct exception was reported while the request completes normally.
+
+## Call `Event::fake()` After Factory Setup
+
+Model factories rely on model events (e.g., `creating` to generate UUIDs). Calling `Event::fake()` before factory calls silences those events, producing broken models.
+
+Incorrect: `Event::fake(); $user = User::factory()->create();`
+
+Correct: `$user = User::factory()->create(); Event::fake();`
+
+## Use `recycle()` to Share Relationship Instances Across Factories
+
+Without `recycle()`, nested factories create separate instances of the same conceptual entity.
+
+```php
+Ticket::factory()
+ ->recycle(Airline::factory()->create())
+ ->create();
+```
diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md
new file mode 100644
index 00000000..5fde1064
--- /dev/null
+++ b/.claude/skills/laravel-best-practices/rules/validation.md
@@ -0,0 +1,75 @@
+# Validation & Forms Best Practices
+
+## Use Form Request Classes
+
+Extract validation from controllers into dedicated Form Request classes.
+
+Incorrect:
+```php
+public function store(Request $request)
+{
+ $request->validate([
+ 'title' => 'required|max:255',
+ 'body' => 'required',
+ ]);
+}
+```
+
+Correct:
+```php
+public function store(StorePostRequest $request)
+{
+ Post::create($request->validated());
+}
+```
+
+## Array vs. String Notation for Rules
+
+Array syntax is more readable and composes cleanly with `Rule::` objects. Prefer it in new code, but check existing Form Requests first and match whatever notation the project already uses.
+
+```php
+// Preferred for new code
+'email' => ['required', 'email', Rule::unique('users')],
+
+// Follow existing convention if the project uses string notation
+'email' => 'required|email|unique:users',
+```
+
+## Always Use `validated()`
+
+Get only validated data. Never use `$request->all()` for mass operations.
+
+Incorrect:
+```php
+Post::create($request->all());
+```
+
+Correct:
+```php
+Post::create($request->validated());
+```
+
+## Use `Rule::when()` for Conditional Validation
+
+```php
+'company_name' => [
+ Rule::when($this->account_type === 'business', ['required', 'string', 'max:255']),
+],
+```
+
+## Use the `after()` Method for Custom Validation
+
+Use `after()` instead of `withValidator()` for custom validation logic that depends on multiple fields.
+
+```php
+public function after(): array
+{
+ return [
+ function (Validator $validator) {
+ if ($this->quantity > Product::find($this->product_id)?->stock) {
+ $validator->errors()->add('quantity', 'Not enough stock.');
+ }
+ },
+ ];
+}
+```
diff --git a/.claude/skills/livewire-development/SKILL.md b/.claude/skills/livewire-development/SKILL.md
new file mode 100644
index 00000000..0ae356e5
--- /dev/null
+++ b/.claude/skills/livewire-development/SKILL.md
@@ -0,0 +1,175 @@
+---
+name: livewire-development
+description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Livewire Development
+
+## Documentation
+
+Use `search-docs` for detailed Livewire 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Components
+
+```bash
+
+# Single-file component (SFC - default in v4)
+
+# Creates: resources/views/components/⚡create-post.blade.php
+
+php artisan make:livewire create-post
+
+# Page component (SFC - Full Page in v4)
+
+# Creates: resources/views/pages/⚡create-post.blade.php
+
+php artisan make:livewire pages::create-post
+
+# Multi-file component (MFC)
+
+# Creates: resources/views/components/⚡create-post/create-post.php
+
+# resources/views/components/⚡create-post/create-post.blade.php
+
+php artisan make:livewire create-post --mfc
+
+# Class-based component (v3 style)
+
+# Creates: app/Livewire/CreatePost.php AND resources/views/livewire/create-post.blade.php
+
+php artisan make:livewire create-post --class
+
+# With namespace
+
+php artisan make:livewire Posts/CreatePost
+```
+
+### Converting Between Formats
+
+Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
+
+### Choosing a Component Format
+
+> **Always follow the project's existing conventions first.** Before creating any component, inspect the project's existing Livewire components to determine the established format (SFC, MFC, or class-based) and directory structure. Check `app/Livewire/`, `resources/views/components/`, and `resources/views/livewire/` for existing components. If the project already uses a consistent format, **use that same format** — even if it differs from the Livewire v4 defaults below. Only fall back to the v4 defaults (SFC in `resources/views/components/`) when no existing convention is established.
+
+Also check `config/livewire.php` for `make_command.type`, `make_command.emoji`, `component_locations`, and `component_namespaces` overrides, which change the default format and where files are stored.
+
+### Component Format Reference
+
+| Format | Flag | Class Path | View Path |
+|--------|------|------------|-----------|
+| Single-file (SFC) | default | — | `resources/views/components/⚡create-post.blade.php` (PHP + Blade in one file) |
+| Full Page SFC | `pages::name` | — | `resources/views/pages/⚡create-post.blade.php` |
+| Multi-file (MFC) | `--mfc` | `resources/views/components/⚡create-post/create-post.php` | `resources/views/components/⚡create-post/create-post.blade.php` |
+| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
+| View-based | default (Blade-only) | — | `resources/views/components/⚡create-post.blade.php` (Blade-only with functional state) |
+
+> **Important:** The ⚡ prefix shown above is the **default** behavior in Livewire v4 — it is **configurable**. Check `config/livewire.php` for the `make_command.emoji` setting. When `true` (default), always include the ⚡ prefix in filenames you create. When `false`, omit the ⚡ prefix from all paths above.
+
+Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates `resources/views/components/posts/⚡create-post.blade.php` (single-file by default). Use `make:livewire Posts/CreatePost --mfc` for multi-file output at `resources/views/components/posts/⚡create-post/create-post.php` and `resources/views/components/posts/⚡create-post/create-post.blade.php`.
+
+### Single-File Component Example
+
+
+```php
+count++;
+ }
+};
+?>
+
+
+ Count: @{{ $count }}
+
+```
+
+## Livewire 4 Specifics
+
+### Key Changes From Livewire 3
+
+These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
+
+- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout` → `component_layout`, `lazy_placeholder` → `component_placeholder`.
+- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
+- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
+- JavaScript: `$wire.$js('name', fn)` → `$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
+
+### New Features
+
+- Component formats: single-file (SFC), multi-file (MFC), view-based components.
+- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
+- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
+
+| Feature | Usage | Purpose |
+|---------|-------|---------|
+| Islands | `@island(name: 'stats')` | Isolated update regions |
+| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
+| Deferred | `defer` attribute | Load after page render |
+| Bundled | `lazy.bundle` | Load multiple together |
+
+### New Directives
+
+- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
+- `data-loading` attribute automatically added to elements triggering network requests.
+
+| Directive | Purpose |
+|-----------|---------|
+| `wire:sort` | Drag-and-drop sorting |
+| `wire:intersect` | Viewport intersection detection |
+| `wire:ref` | Element references for JS |
+| `.renderless` | Component without rendering |
+| `.preserve-scroll` | Preserve scroll position |
+
+## Best Practices
+
+- Always use `wire:key` in loops
+- Use `wire:loading` for loading states
+- Use `wire:model.live` for instant updates (default is debounced)
+- Validate and authorize in actions (treat like HTTP requests)
+
+## Configuration
+
+- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
+
+## Alpine & JavaScript
+
+- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
+- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
+
+For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
+
+## Testing
+
+
+```php
+Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1);
+```
+
+## Verification
+
+1. Browser console: Check for JS errors
+2. Network tab: Verify Livewire requests return 200
+3. Ensure `wire:key` on all `@foreach` loops
+
+## Common Pitfalls
+
+- Missing `wire:key` in loops → unexpected re-rendering
+- Expecting `wire:model` real-time → use `wire:model.live`
+- Unclosed component tags → syntax errors in v4
+- Using deprecated config keys or JS hooks
+- Including Alpine.js separately (already bundled in Livewire 4)
diff --git a/.claude/skills/livewire-development/reference/javascript-hooks.md b/.claude/skills/livewire-development/reference/javascript-hooks.md
new file mode 100644
index 00000000..660d66b5
--- /dev/null
+++ b/.claude/skills/livewire-development/reference/javascript-hooks.md
@@ -0,0 +1,39 @@
+# Livewire 4 JavaScript Integration
+
+## Interceptor System (v4)
+
+### Intercept Messages
+
+```js
+Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
+ onFinish(() => { /* After response, before processing */ });
+ onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
+ onError(() => { /* Server errors */ });
+});
+```
+
+### Intercept Requests
+
+```js
+Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
+ onResponse(({ response }) => { /* When received */ });
+ onSuccess(({ response, responseJson }) => { /* Success */ });
+ onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
+ onFailure(({ error }) => { /* Network failures */ });
+});
+```
+
+### Component-Scoped Interceptors
+
+```blade
+
+```
+
+## Magic Properties
+
+- `$errors` - Access validation errors from JavaScript
+- `$intercept` - Component-scoped interceptors
diff --git a/.claude/skills/pest-testing/SKILL.md b/.claude/skills/pest-testing/SKILL.md
new file mode 100644
index 00000000..ab271616
--- /dev/null
+++ b/.claude/skills/pest-testing/SKILL.md
@@ -0,0 +1,166 @@
+---
+name: pest-testing
+description: "Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: test()/it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Pest Testing 4
+
+## Documentation
+
+Use `search-docs` for detailed Pest 4 patterns and documentation.
+
+## Basic Usage
+
+### Creating Tests
+
+All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
+
+The `{name}` argument should include only the path and test name, but should not include the test suite.
+- Incorrect: `php artisan make:test --pest Feature/SomeFeatureTest` will generate `tests/Feature/Feature/SomeFeatureTest.php`
+- Correct: `php artisan make:test --pest SomeControllerTest` will generate `tests/Feature/SomeControllerTest.php`
+- Incorrect: `php artisan make:test --pest --unit Unit/SomeServiceTest` will generate `tests/Unit/Unit/SomeServiceTest.php`
+- Correct: `php artisan make:test --pest --unit SomeServiceTest` will generate `tests/Unit/SomeServiceTest.php`
+
+### Test Organization
+
+- Unit/Feature tests: `tests/Feature` and `tests/Unit` directories.
+- Browser tests: `tests/Browser/` directory.
+- Do NOT remove tests without approval - these are core application code.
+
+### Basic Test Structure
+
+Pest supports both `test()` and `it()` functions. Before writing new tests, check existing test files in the same directory to match the project's convention. Use `test()` if existing tests use `test()`, or `it()` if they use `it()`.
+
+
+```php
+it('is true', function () {
+ expect(true)->toBeTrue();
+});
+```
+
+### Running Tests
+
+- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
+- Run all tests: `php artisan test --compact`.
+- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+
+## Assertions
+
+Use specific assertions (`assertSuccessful()`, `assertNotFound()`) instead of `assertStatus()`:
+
+
+```php
+it('returns all', function () {
+ $this->postJson('/api/docs', [])->assertSuccessful();
+});
+```
+
+| Use | Instead of |
+|-----|------------|
+| `assertSuccessful()` | `assertStatus(200)` |
+| `assertNotFound()` | `assertStatus(404)` |
+| `assertForbidden()` | `assertStatus(403)` |
+
+## Mocking
+
+Import mock function before use: `use function Pest\Laravel\mock;`
+
+## Datasets
+
+Use datasets for repetitive tests (validation rules, etc.):
+
+
+```php
+it('has emails', function (string $email) {
+ expect($email)->not->toBeEmpty();
+})->with([
+ 'james' => 'james@laravel.com',
+ 'taylor' => 'taylor@laravel.com',
+]);
+```
+
+## Pest 4 Features
+
+| Feature | Purpose |
+|---------|---------|
+| Browser Testing | Full integration tests in real browsers |
+| Smoke Testing | Validate multiple pages quickly |
+| Visual Regression | Compare screenshots for visual changes |
+| Test Sharding | Parallel CI runs |
+| Architecture Testing | Enforce code conventions |
+
+### Browser Test Example
+
+Browser tests run in real browsers for full integration testing:
+
+- Browser tests live in `tests/Browser/`.
+- Use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories.
+- Use `RefreshDatabase` for clean state per test.
+- Interact with page: click, type, scroll, select, submit, drag-and-drop, touch gestures.
+- Test on multiple browsers (Chrome, Firefox, Safari) if requested.
+- Test on different devices/viewports (iPhone 14 Pro, tablets) if requested.
+- Switch color schemes (light/dark mode) when appropriate.
+- Take screenshots or pause tests for debugging.
+
+
+```php
+it('may reset the password', function () {
+ Notification::fake();
+
+ $this->actingAs(User::factory()->create());
+
+ $page = visit('/sign-in');
+
+ $page->assertSee('Sign In')
+ ->assertNoJavaScriptErrors()
+ ->click('Forgot Password?')
+ ->fill('email', 'nuno@laravel.com')
+ ->click('Send Reset Link')
+ ->assertSee('We have emailed your password reset link!');
+
+ Notification::assertSent(ResetPassword::class);
+});
+```
+
+### Smoke Testing
+
+Quickly validate multiple pages have no JavaScript errors:
+
+
+```php
+$pages = visit(['/', '/about', '/contact']);
+
+$pages->assertNoJavaScriptErrors()->assertNoConsoleLogs();
+```
+
+### Visual Regression Testing
+
+Capture and compare screenshots to detect visual changes.
+
+### Test Sharding
+
+Split tests across parallel processes for faster CI runs.
+
+### Architecture Testing
+
+Pest 4 includes architecture testing (from Pest 3):
+
+
+```php
+arch('controllers')
+ ->expect('App\Http\Controllers')
+ ->toExtendNothing()
+ ->toHaveSuffix('Controller');
+```
+
+## Common Pitfalls
+
+- Not importing `use function Pest\Laravel\mock;` before using mock
+- Using `assertStatus(200)` instead of `assertSuccessful()`
+- Forgetting datasets for repetitive validation tests
+- Deleting tests without approval
+- Forgetting `assertNoJavaScriptErrors()` in browser tests
+- Prefixing `Feature/` or `Unit/` in `{name}` when using `make:test`
diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md
new file mode 100644
index 00000000..c0cb2fbc
--- /dev/null
+++ b/.claude/skills/tailwindcss-development/SKILL.md
@@ -0,0 +1,119 @@
+---
+name: tailwindcss-development
+description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS."
+license: MIT
+metadata:
+ author: laravel
+---
+
+# Tailwind CSS Development
+
+## Documentation
+
+Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
+
+## Basic Usage
+
+- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
+- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
+- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
+
+## Tailwind CSS v4 Specifics
+
+- Always use Tailwind CSS v4 and avoid deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+
+### CSS-First Configuration
+
+In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
+
+
+```css
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+```
+
+### Import Syntax
+
+In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
+
+
+```diff
+- @tailwind base;
+- @tailwind components;
+- @tailwind utilities;
++ @import "tailwindcss";
+```
+
+### Replaced Utilities
+
+Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
+
+| Deprecated | Replacement |
+|------------|-------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
+## Spacing
+
+Use `gap` utilities instead of margins for spacing between siblings:
+
+
+```html
+
+```
+
+## Dark Mode
+
+If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
+
+
+```html
+
+ Content adapts to color scheme
+
+```
+
+## Common Patterns
+
+### Flexbox Layout
+
+
+```html
+
+
Left content
+
Right content
+
+```
+
+### Grid Layout
+
+
+```html
+
+
Card 1
+
Card 2
+
Card 3
+
+```
+
+## Common Pitfalls
+
+- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
+- Using `@tailwind` directives instead of `@import "tailwindcss"`
+- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
+- Using margins for spacing between siblings instead of gap utilities
+- Forgetting to add dark mode variants when the project uses dark mode
diff --git a/.codex/config.toml b/.codex/config.toml
new file mode 100644
index 00000000..864e1fbd
--- /dev/null
+++ b/.codex/config.toml
@@ -0,0 +1,3 @@
+[mcp_servers.laravel-boost]
+command = "php"
+args = ["artisan", "boost:mcp"]
diff --git a/.mcp.json b/.mcp.json
index 0ad95248..5d75d4a9 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -3,10 +3,16 @@
"laravel-boost": {
"command": "php",
"args": [
- "./artisan",
+ "artisan",
"boost:mcp"
]
},
+ "playwright": {
+ "command": "npx",
+ "args": [
+ "@playwright/mcp@latest"
+ ]
+ },
"herd": {
"command": "php",
"args": [
diff --git a/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log b/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log
new file mode 100644
index 00000000..dd59948e
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T09-15-35-246Z.log
@@ -0,0 +1 @@
+[ 100ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://shop.test/_boost/browser-logs @ http://shop.test/:27
diff --git a/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log b/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log
new file mode 100644
index 00000000..fda72a8f
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-28-30-241Z.log
@@ -0,0 +1,3 @@
+[ 233ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/:48
+[ 335ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://acme-fashion.test/favicon.ico:0
+[ 11820ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-hdvTSHkI.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/collections/t-shirts:0
diff --git a/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log b/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log
new file mode 100644
index 00000000..b8800a28
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-28-44-297Z.log
@@ -0,0 +1 @@
+[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/products/classic-cotton-t-shirt:48
diff --git a/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log b/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log
new file mode 100644
index 00000000..d9d23dcd
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-28-53-067Z.log
@@ -0,0 +1,3 @@
+[ 69ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/cart:48
+[ 6454ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1:48
+[ 26319ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/checkout/1/confirmation:48
diff --git a/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log b/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log
new file mode 100644
index 00000000..453e726d
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-29-27-652Z.log
@@ -0,0 +1,18 @@
+[ 58ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/login:53
+[ 6808ms] [WARNING] The resource http://acme-fashion.test/build/assets/app-hdvTSHkI.css was preloaded using link preload but not used within a few seconds from the window's load event. Please make sure it has an appropriate `as` value and it is preloaded intentionally. @ http://acme-fashion.test/admin:0
+[ 8400ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/products:53
+[ 8928ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders:53
+[ 9451ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 9954ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/customers:53
+[ 10472ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/discounts:53
+[ 11013ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings:53
+[ 11528ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings/shipping:53
+[ 12019ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/analytics:53
+[ 12512ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/collections:53
+[ 21237ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 29691ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 38011ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/settings/shipping:53
+[ 38095ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/account/login:48
+[ 38103ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "current-password"): (More info: https://goo.gl/9p2vKq) %o @ http://acme-fashion.test/account/login:0
+[ 39467ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 44741ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin:53
diff --git a/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log b/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log
new file mode 100644
index 00000000..ef380af5
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-30-44-355Z.log
@@ -0,0 +1,3 @@
+[ 83ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 13846ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://acme-fashion.test/livewire-0972654c/update:0
+[ 13860ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ :7
diff --git a/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log b/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log
new file mode 100644
index 00000000..b7e6b4df
--- /dev/null
+++ b/.playwright-mcp/console-2026-07-18T11-31-07-313Z.log
@@ -0,0 +1,2 @@
+[ 76ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
+[ 4999ms] [LOG] 🔍 Browser logger active (MCP server detected). Posting to: http://acme-fashion.test/_boost/browser-logs @ http://acme-fashion.test/admin/orders/1:53
diff --git a/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml b/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml
new file mode 100644
index 00000000..ab458677
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T09-15-35-387Z.yml
@@ -0,0 +1,26 @@
+- generic [active] [ref=f1e1]:
+ - banner [ref=f1e2]:
+ - navigation [ref=f1e3]:
+ - link "Log in" [ref=f1e4] [cursor=pointer]:
+ - /url: http://shop.test/login
+ - link "Register" [ref=f1e5] [cursor=pointer]:
+ - /url: http://shop.test/register
+ - main [ref=f1e7]:
+ - generic [ref=f1e8]:
+ - heading "Let's get started" [level=1] [ref=f1e9]
+ - paragraph [ref=f1e10]: Laravel has an incredibly rich ecosystem. We suggest starting with the following.
+ - list [ref=f1e11]:
+ - listitem [ref=f1e12]:
+ - generic [ref=f1e16]:
+ - text: Read the
+ - link "Documentation" [ref=f1e17] [cursor=pointer]:
+ - /url: https://laravel.com/docs
+ - listitem [ref=f1e21]:
+ - generic [ref=f1e25]:
+ - text: Watch video tutorials at
+ - link "Laracasts" [ref=f1e26] [cursor=pointer]:
+ - /url: https://laracasts.com
+ - list [ref=f1e30]:
+ - listitem [ref=f1e31]:
+ - link "Deploy now" [ref=f1e32] [cursor=pointer]:
+ - /url: https://cloud.laravel.com
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml b/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml
new file mode 100644
index 00000000..06a51217
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-30-586Z.yml
@@ -0,0 +1,159 @@
+- generic [active] [ref=e1]:
+ - link "Skip to main content" [ref=e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=e3]:
+ - generic [ref=e4]:
+ - link "Acme Fashion" [ref=e5] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=e6]:
+ - link "Home" [ref=e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=e8] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=e9] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=e11] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=e12]:
+ - link "Search" [ref=e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - button "Open cart" [ref=e17]
+ - link "Account" [ref=e20] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=e23]:
+ - generic [ref=e24]:
+ - generic [ref=e26]:
+ - heading "Welcome to Acme Fashion" [level=1] [ref=e27]
+ - paragraph [ref=e28]: Discover our latest collections and find something you'll love.
+ - link "Shop now" [ref=e30] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections
+ - generic [ref=e31]:
+ - heading "Shop by Collection" [level=2] [ref=e32]
+ - generic [ref=e33]:
+ - link [ref=e34] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - paragraph [ref=e36]: New Arrivals
+ - link [ref=e37] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - paragraph [ref=e39]: Pants & Jeans
+ - link [ref=e40] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - paragraph [ref=e42]: Sale
+ - link [ref=e43] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - paragraph [ref=e45]: T-Shirts
+ - generic [ref=e46]:
+ - heading "Featured Products" [level=2] [ref=e47]
+ - generic [ref=e48]:
+ - generic [ref=e49]:
+ - link [ref=e50] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/cashmere-overcoat
+ - heading "Cashmere Overcoat" [level=3] [ref=e55]
+ - generic [ref=e56]: 499.99 EUR
+ - link "Choose options" [ref=e59] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/cashmere-overcoat
+ - generic [ref=e60]:
+ - link [ref=e61] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/gift-card
+ - heading "Gift Card" [level=3] [ref=e66]
+ - generic [ref=e67]: 25.00 EUR
+ - link "Choose options" [ref=e70] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/gift-card
+ - generic [ref=e71]:
+ - link [ref=e72] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/backorder-denim-jacket
+ - heading "Backorder Denim Jacket" [level=3] [ref=e77]
+ - generic [ref=e78]: 99.99 EUR
+ - link "Choose options" [ref=e81] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/backorder-denim-jacket
+ - generic [ref=e82]:
+ - link "Sold out Limited Edition Sneakers" [ref=e83] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/limited-edition-sneakers
+ - generic [ref=e84]: Sold out
+ - heading "Limited Edition Sneakers" [level=3] [ref=e90]
+ - generic [ref=e91]: 159.99 EUR
+ - link "Choose options" [ref=e94] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/limited-edition-sneakers
+ - generic [ref=e95]:
+ - link [ref=e96] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/bucket-hat
+ - heading "Bucket Hat" [level=3] [ref=e101]
+ - generic [ref=e102]: 24.99 EUR
+ - link "Choose options" [ref=e105] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/bucket-hat
+ - generic [ref=e106]:
+ - link [ref=e107] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/canvas-tote-bag
+ - heading "Canvas Tote Bag" [level=3] [ref=e112]
+ - generic [ref=e113]: 19.99 EUR
+ - link "Choose options" [ref=e116] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/canvas-tote-bag
+ - generic [ref=e117]:
+ - link [ref=e118] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/wool-scarf
+ - heading "Wool Scarf" [level=3] [ref=e123]
+ - generic [ref=e124]: 29.99 EUR
+ - link "Choose options" [ref=e127] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/wool-scarf
+ - generic [ref=e128]:
+ - link "Sale Wide Leg Trousers" [ref=e129] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/wide-leg-trousers
+ - generic [ref=e130]: Sale
+ - heading "Wide Leg Trousers" [level=3] [ref=e136]
+ - generic [ref=e138]:
+ - generic [ref=e139]: 49.99 EUR
+ - generic [ref=e140]: 69.99 EUR
+ - generic [ref=e141]: Sale
+ - link "Choose options" [ref=e142] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/wide-leg-trousers
+ - generic [ref=e144]:
+ - heading "Stay in the loop" [level=2] [ref=e145]
+ - paragraph [ref=e146]: Subscribe for exclusive offers and new arrivals.
+ - generic [ref=e147]:
+ - textbox "Email address" [ref=e149]:
+ - /placeholder: Your email address
+ - button "Subscribe" [ref=e150]
+ - contentinfo [ref=e156]:
+ - generic [ref=e157]:
+ - generic [ref=e158]:
+ - generic [ref=e159]:
+ - heading "Shop" [level=3] [ref=e160]
+ - list [ref=e161]:
+ - listitem [ref=e162]:
+ - link "About Us" [ref=e163] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=e164]:
+ - link "FAQ" [ref=e165] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=e166]:
+ - link "Shipping & Returns" [ref=e167] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=e168]:
+ - link "Privacy Policy" [ref=e169] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=e170]:
+ - link "Terms of Service" [ref=e171] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=e172]:
+ - heading "Acme Fashion" [level=3] [ref=e173]
+ - paragraph [ref=e175]: Acme Fashion
+ - generic [ref=e176]:
+ - link "Acme Fashion on Facebook" [ref=e177] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=e180] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=e183] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=e186] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=e189] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=e192]:
+ - paragraph [ref=e193]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=e194]:
+ - generic [ref=e195]: Visa
+ - generic [ref=e196]: Mastercard
+ - generic [ref=e197]: Amex
+ - generic [ref=e198]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml b/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml
new file mode 100644
index 00000000..ab1205f2
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-40-022Z.yml
@@ -0,0 +1,147 @@
+- generic [active] [ref=e199]:
+ - link "Skip to main content" [ref=e200] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=e201]:
+ - generic [ref=e202]:
+ - link "Acme Fashion" [ref=e203] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=e204]:
+ - link "Home" [ref=e205] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=e206] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=e207] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=e208] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=e209] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=e210]:
+ - link "Search" [ref=e211] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - button "Open cart" [ref=e215]
+ - link "Account" [ref=e218] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=e221]:
+ - generic [ref=e222]:
+ - navigation "Breadcrumb" [ref=e223]:
+ - list [ref=e224]:
+ - listitem [ref=e225]:
+ - link "Home" [ref=e226] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - generic [ref=e227]: /
+ - listitem [ref=e228]:
+ - link "Collections" [ref=e229] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections
+ - generic [ref=e230]: /
+ - listitem [ref=e231]:
+ - generic [ref=e232]: T-Shirts
+ - generic [ref=e233]:
+ - generic [ref=e234]:
+ - heading "T-Shirts" [level=1] [ref=e235]
+ - paragraph [ref=e237]: Premium cotton tees for every occasion.
+ - generic [ref=e238]:
+ - generic [ref=e239]: Sort by
+ - combobox "Sort by" [ref=e240]:
+ - option "Featured" [selected]
+ - option "Newest"
+ - 'option "Price: Low to High"'
+ - 'option "Price: High to Low"'
+ - generic [ref=e241]:
+ - complementary "Filters" [ref=e242]:
+ - heading "Filters" [level=2] [ref=e244]
+ - generic [ref=e245]:
+ - generic [ref=e246]:
+ - checkbox "In stock only" [ref=e247]
+ - text: In stock only
+ - generic [ref=e248]:
+ - paragraph [ref=e249]: Price
+ - generic [ref=e250]:
+ - spinbutton "Minimum price" [ref=e252]
+ - generic [ref=e254]: "-"
+ - spinbutton "Maximum price" [ref=e256]
+ - generic [ref=e258]:
+ - paragraph [ref=e259]: Product type
+ - generic [ref=e261]:
+ - checkbox "T-Shirts" [ref=e262]
+ - text: T-Shirts
+ - generic [ref=e263]:
+ - paragraph [ref=e264]: Vendor
+ - generic [ref=e266]:
+ - checkbox "Acme Basics" [ref=e267]
+ - text: Acme Basics
+ - generic [ref=e269]:
+ - generic [ref=e270]:
+ - link [ref=e271] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/classic-cotton-t-shirt
+ - heading "Classic Cotton T-Shirt" [level=3] [ref=e276]
+ - generic [ref=e277]: 24.99 EUR
+ - link "Choose options" [ref=e280] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/classic-cotton-t-shirt
+ - generic [ref=e281]:
+ - link [ref=e282] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/graphic-print-tee
+ - heading "Graphic Print Tee" [level=3] [ref=e287]
+ - generic [ref=e288]: 29.99 EUR
+ - link "Choose options" [ref=e291] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/graphic-print-tee
+ - generic [ref=e292]:
+ - link [ref=e293] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/v-neck-linen-tee
+ - heading "V-Neck Linen Tee" [level=3] [ref=e298]
+ - generic [ref=e299]: 34.99 EUR
+ - link "Choose options" [ref=e302] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/v-neck-linen-tee
+ - generic [ref=e303]:
+ - link "Sale Striped Polo Shirt" [ref=e304] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/striped-polo-shirt
+ - generic [ref=e305]: Sale
+ - heading "Striped Polo Shirt" [level=3] [ref=e311]
+ - generic [ref=e313]:
+ - generic [ref=e314]: 27.99 EUR
+ - generic [ref=e315]: 39.99 EUR
+ - generic [ref=e316]: Sale
+ - link "Choose options" [ref=e317] [cursor=pointer]:
+ - /url: http://acme-fashion.test/products/striped-polo-shirt
+ - contentinfo [ref=e318]:
+ - generic [ref=e319]:
+ - generic [ref=e320]:
+ - generic [ref=e321]:
+ - heading "Shop" [level=3] [ref=e322]
+ - list [ref=e323]:
+ - listitem [ref=e324]:
+ - link "About Us" [ref=e325] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=e326]:
+ - link "FAQ" [ref=e327] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=e328]:
+ - link "Shipping & Returns" [ref=e329] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=e330]:
+ - link "Privacy Policy" [ref=e331] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=e332]:
+ - link "Terms of Service" [ref=e333] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=e334]:
+ - heading "Acme Fashion" [level=3] [ref=e335]
+ - paragraph [ref=e337]: Acme Fashion
+ - generic [ref=e338]:
+ - link "Acme Fashion on Facebook" [ref=e339] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=e342] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=e345] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=e348] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=e351] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=e354]:
+ - paragraph [ref=e355]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=e356]:
+ - generic [ref=e357]: Visa
+ - generic [ref=e358]: Mastercard
+ - generic [ref=e359]: Amex
+ - generic [ref=e360]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml b/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml
new file mode 100644
index 00000000..ab7c7b77
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-44-401Z.yml
@@ -0,0 +1,107 @@
+- generic [active] [ref=f1e1]:
+ - link "Skip to main content" [ref=f1e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=f1e3]:
+ - generic [ref=f1e4]:
+ - link "Acme Fashion" [ref=f1e5] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=f1e6]:
+ - link "Home" [ref=f1e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=f1e8] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=f1e9] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=f1e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=f1e11] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=f1e12]:
+ - link "Search" [ref=f1e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - button "Open cart" [ref=f1e17]
+ - link "Account" [ref=f1e20] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=f1e23]:
+ - generic [ref=f1e24]:
+ - navigation "Breadcrumb" [ref=f1e25]:
+ - list [ref=f1e26]:
+ - listitem [ref=f1e27]:
+ - link "Home" [ref=f1e28] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - generic [ref=f1e29]: /
+ - listitem [ref=f1e30]:
+ - link "New Arrivals" [ref=f1e31] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - generic [ref=f1e32]: /
+ - listitem [ref=f1e33]:
+ - generic [ref=f1e34]: Classic Cotton T-Shirt
+ - generic [ref=f1e35]:
+ - region "Product images" [ref=f1e36]
+ - generic [ref=f1e41]:
+ - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e42]
+ - generic [ref=f1e43]: 24.99 EUR
+ - group "Size" [ref=f1e46]:
+ - generic [ref=f1e48]:
+ - button "S" [pressed] [ref=f1e49]
+ - button "M" [ref=f1e50]
+ - button "L" [ref=f1e51]
+ - button "XL" [ref=f1e52]
+ - group "Color" [ref=f1e53]:
+ - generic [ref=f1e55]:
+ - button "White" [pressed] [ref=f1e56]
+ - button "Black" [ref=f1e57]
+ - button "Navy" [ref=f1e58]
+ - generic [ref=f1e59]: In stock
+ - generic [ref=f1e64]:
+ - button "Decrease quantity" [disabled] [ref=f1e65]
+ - generic [ref=f1e67]: Quantity
+ - spinbutton "Quantity" [ref=f1e68]: "1"
+ - button "Increase quantity" [ref=f1e69]
+ - button "Add to cart" [ref=f1e72]
+ - paragraph [ref=f1e79]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.
+ - generic [ref=f1e80]:
+ - generic [ref=f1e81]: new
+ - generic [ref=f1e82]: popular
+ - contentinfo [ref=f1e83]:
+ - generic [ref=f1e84]:
+ - generic [ref=f1e85]:
+ - generic [ref=f1e86]:
+ - heading "Shop" [level=3] [ref=f1e87]
+ - list [ref=f1e88]:
+ - listitem [ref=f1e89]:
+ - link "About Us" [ref=f1e90] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=f1e91]:
+ - link "FAQ" [ref=f1e92] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=f1e93]:
+ - link "Shipping & Returns" [ref=f1e94] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=f1e95]:
+ - link "Privacy Policy" [ref=f1e96] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=f1e97]:
+ - link "Terms of Service" [ref=f1e98] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=f1e99]:
+ - heading "Acme Fashion" [level=3] [ref=f1e100]
+ - paragraph [ref=f1e102]: Acme Fashion
+ - generic [ref=f1e103]:
+ - link "Acme Fashion on Facebook" [ref=f1e104] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=f1e107] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=f1e110] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=f1e113] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=f1e116] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=f1e119]:
+ - paragraph [ref=f1e120]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=f1e121]:
+ - generic [ref=f1e122]: Visa
+ - generic [ref=f1e123]: Mastercard
+ - generic [ref=f1e124]: Amex
+ - generic [ref=f1e125]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml b/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml
new file mode 100644
index 00000000..813ca031
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-51-361Z.yml
@@ -0,0 +1,144 @@
+- generic [active] [ref=f1e1]:
+ - link "Skip to main content" [ref=f1e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=f1e3]:
+ - generic [ref=f1e4]:
+ - link "Acme Fashion" [ref=f1e5] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=f1e6]:
+ - link "Home" [ref=f1e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=f1e8] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=f1e9] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=f1e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=f1e11] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=f1e12]:
+ - link "Search" [ref=f1e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - generic [ref=f1e16]:
+ - button "Open cart" [ref=f1e17]:
+ - generic [ref=f1e126]: "1"
+ - dialog "Shopping cart" [ref=f1e127]:
+ - generic [ref=f1e129]:
+ - generic [ref=f1e130]:
+ - heading "Your Cart (1)" [level=2] [ref=f1e131]
+ - button "Close cart" [ref=f1e132]
+ - list [ref=f1e136]:
+ - listitem [ref=f1e137]:
+ - generic [ref=f1e139]:
+ - paragraph [ref=f1e140]: Classic Cotton T-Shirt
+ - paragraph [ref=f1e141]: S / White
+ - generic [ref=f1e142]:
+ - generic [ref=f1e143]:
+ - button "Decrease quantity" [disabled] [ref=f1e144]
+ - generic [ref=f1e146]: Quantity
+ - spinbutton "Quantity" [ref=f1e147]: "1"
+ - button "Increase quantity" [ref=f1e148]
+ - generic [ref=f1e151]: 24.99 EUR
+ - button "Remove Classic Cotton T-Shirt from cart" [ref=f1e153]
+ - generic [ref=f1e156]:
+ - generic [ref=f1e157]:
+ - textbox "Discount code" [ref=f1e159]
+ - button "Apply" [ref=f1e160]
+ - generic [ref=f1e166]:
+ - generic [ref=f1e167]:
+ - term [ref=f1e168]: Subtotal
+ - definition [ref=f1e169]:
+ - generic [ref=f1e170]: 24.99 EUR
+ - generic [ref=f1e172]:
+ - term [ref=f1e173]: Estimated total
+ - definition [ref=f1e174]:
+ - generic [ref=f1e175]: 24.99 EUR
+ - paragraph [ref=f1e177]: Shipping and taxes calculated at checkout.
+ - button "Checkout" [ref=f1e178]
+ - button "Continue shopping" [ref=f1e185]
+ - link "Account" [ref=f1e20] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=f1e23]:
+ - generic [ref=f1e24]:
+ - navigation "Breadcrumb" [ref=f1e25]:
+ - list [ref=f1e26]:
+ - listitem [ref=f1e27]:
+ - link "Home" [ref=f1e28] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - generic [ref=f1e29]: /
+ - listitem [ref=f1e30]:
+ - link "New Arrivals" [ref=f1e31] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - generic [ref=f1e32]: /
+ - listitem [ref=f1e33]:
+ - generic [ref=f1e34]: Classic Cotton T-Shirt
+ - generic [ref=f1e35]:
+ - region "Product images" [ref=f1e36]
+ - generic [ref=f1e41]:
+ - heading "Classic Cotton T-Shirt" [level=1] [ref=f1e42]
+ - generic [ref=f1e43]: 24.99 EUR
+ - group "Size" [ref=f1e46]:
+ - generic [ref=f1e48]:
+ - button "S" [pressed] [ref=f1e49]
+ - button "M" [ref=f1e50]
+ - button "L" [ref=f1e51]
+ - button "XL" [ref=f1e52]
+ - group "Color" [ref=f1e53]:
+ - generic [ref=f1e55]:
+ - button "White" [pressed] [ref=f1e56]
+ - button "Black" [ref=f1e57]
+ - button "Navy" [ref=f1e58]
+ - generic [ref=f1e59]: In stock
+ - generic [ref=f1e64]:
+ - button "Decrease quantity" [disabled] [ref=f1e65]
+ - generic [ref=f1e67]: Quantity
+ - spinbutton "Quantity" [ref=f1e68]: "1"
+ - button "Increase quantity" [ref=f1e69]
+ - button "Add to cart" [ref=f1e72]
+ - status [ref=f1e186]: Added to cart
+ - paragraph [ref=f1e79]: A timeless classic cotton t-shirt. Comfortable, breathable, and perfect for everyday wear.
+ - generic [ref=f1e80]:
+ - generic [ref=f1e81]: new
+ - generic [ref=f1e82]: popular
+ - contentinfo [ref=f1e83]:
+ - generic [ref=f1e84]:
+ - generic [ref=f1e85]:
+ - generic [ref=f1e86]:
+ - heading "Shop" [level=3] [ref=f1e87]
+ - list [ref=f1e88]:
+ - listitem [ref=f1e89]:
+ - link "About Us" [ref=f1e90] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=f1e91]:
+ - link "FAQ" [ref=f1e92] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=f1e93]:
+ - link "Shipping & Returns" [ref=f1e94] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=f1e95]:
+ - link "Privacy Policy" [ref=f1e96] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=f1e97]:
+ - link "Terms of Service" [ref=f1e98] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=f1e99]:
+ - heading "Acme Fashion" [level=3] [ref=f1e100]
+ - paragraph [ref=f1e102]: Acme Fashion
+ - generic [ref=f1e103]:
+ - link "Acme Fashion on Facebook" [ref=f1e104] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=f1e107] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=f1e110] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=f1e113] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=f1e116] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=f1e119]:
+ - paragraph [ref=f1e120]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=f1e121]:
+ - generic [ref=f1e122]: Visa
+ - generic [ref=f1e123]: Mastercard
+ - generic [ref=f1e124]: Amex
+ - generic [ref=f1e125]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml b/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml
new file mode 100644
index 00000000..7a9171c3
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-53-172Z.yml
@@ -0,0 +1,112 @@
+- generic [active] [ref=f2e1]:
+ - link "Skip to main content" [ref=f2e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=f2e3]:
+ - generic [ref=f2e4]:
+ - link "Acme Fashion" [ref=f2e5] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=f2e6]:
+ - link "Home" [ref=f2e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=f2e8] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=f2e9] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=f2e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=f2e11] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=f2e12]:
+ - link "Search" [ref=f2e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - button "Open cart" [ref=f2e17]:
+ - generic [ref=f2e20]: "1"
+ - link "Account" [ref=f2e21] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=f2e24]:
+ - generic [ref=f2e25]:
+ - heading "Your Cart" [level=1] [ref=f2e26]
+ - generic [ref=f2e27]:
+ - table [ref=f2e29]:
+ - rowgroup [ref=f2e30]:
+ - row [ref=f2e31]:
+ - columnheader "Product" [ref=f2e32]
+ - columnheader "Price" [ref=f2e33]
+ - columnheader "Quantity" [ref=f2e34]
+ - columnheader "Total" [ref=f2e35]
+ - columnheader "Remove" [ref=f2e36]
+ - rowgroup [ref=f2e38]:
+ - row [ref=f2e39]:
+ - cell "Classic Cotton T-Shirt S / White" [ref=f2e40]:
+ - generic [ref=f2e43]:
+ - paragraph [ref=f2e44]: Classic Cotton T-Shirt
+ - paragraph [ref=f2e45]: S / White
+ - cell "24.99 EUR" [ref=f2e46]
+ - cell "Decrease quantity Quantity Increase quantity" [ref=f2e49]:
+ - generic [ref=f2e50]:
+ - button "Decrease quantity" [disabled] [ref=f2e51]
+ - generic [ref=f2e53]: Quantity
+ - spinbutton [ref=f2e54]: "1"
+ - button "Increase quantity" [ref=f2e55]
+ - cell "24.99 EUR" [ref=f2e58]
+ - cell [ref=f2e61]:
+ - button "Remove Classic Cotton T-Shirt from cart" [ref=f2e62]
+ - generic [ref=f2e66]:
+ - generic [ref=f2e67]:
+ - textbox "Discount code" [ref=f2e69]
+ - button "Apply" [ref=f2e70]
+ - generic [ref=f2e76]:
+ - generic [ref=f2e77]:
+ - term [ref=f2e78]: Subtotal
+ - definition [ref=f2e79]:
+ - generic [ref=f2e80]: 24.99 EUR
+ - generic [ref=f2e82]:
+ - term [ref=f2e83]: Total
+ - definition [ref=f2e84]:
+ - generic [ref=f2e85]: 24.99 EUR
+ - paragraph [ref=f2e87]: Shipping and taxes calculated at checkout.
+ - button "Checkout" [ref=f2e88]
+ - link "Continue shopping" [ref=f2e95] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - contentinfo [ref=f2e96]:
+ - generic [ref=f2e97]:
+ - generic [ref=f2e98]:
+ - generic [ref=f2e99]:
+ - heading "Shop" [level=3] [ref=f2e100]
+ - list [ref=f2e101]:
+ - listitem [ref=f2e102]:
+ - link "About Us" [ref=f2e103] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=f2e104]:
+ - link "FAQ" [ref=f2e105] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=f2e106]:
+ - link "Shipping & Returns" [ref=f2e107] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=f2e108]:
+ - link "Privacy Policy" [ref=f2e109] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=f2e110]:
+ - link "Terms of Service" [ref=f2e111] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=f2e112]:
+ - heading "Acme Fashion" [level=3] [ref=f2e113]
+ - paragraph [ref=f2e115]: Acme Fashion
+ - generic [ref=f2e116]:
+ - link "Acme Fashion on Facebook" [ref=f2e117] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=f2e120] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=f2e123] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=f2e126] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=f2e129] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=f2e132]:
+ - paragraph [ref=f2e133]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=f2e134]:
+ - generic [ref=f2e135]: Visa
+ - generic [ref=f2e136]: Mastercard
+ - generic [ref=f2e137]: Amex
+ - generic [ref=f2e138]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml b/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml
new file mode 100644
index 00000000..92b53569
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-28-59-558Z.yml
@@ -0,0 +1,159 @@
+- generic [ref=f3e1]:
+ - link "Skip to main content" [ref=f3e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=f3e3]:
+ - generic [ref=f3e4]:
+ - link "Acme Fashion" [ref=f3e5] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - navigation "Main" [ref=f3e6]:
+ - link "Home" [ref=f3e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=f3e8] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/new-arrivals
+ - link "T-Shirts" [ref=f3e9] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/t-shirts
+ - link "Pants & Jeans" [ref=f3e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/pants-jeans
+ - link "Sale" [ref=f3e11] [cursor=pointer]:
+ - /url: http://acme-fashion.test/collections/sale
+ - generic [ref=f3e12]:
+ - link "Search" [ref=f3e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/search
+ - button "Open cart" [ref=f3e17]:
+ - generic [ref=f3e20]: "1"
+ - link "Account" [ref=f3e21] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - main [ref=f3e24]:
+ - generic [ref=f3e25]:
+ - heading "Checkout" [level=1] [ref=f3e26]
+ - generic [ref=f3e27]:
+ - generic [ref=f3e28]:
+ - generic [ref=f3e29]:
+ - heading "1. Contact & Shipping Address" [level=2] [ref=f3e31]
+ - generic [ref=f3e32]:
+ - generic [ref=f3e33]:
+ - generic [ref=f3e34]:
+ - text: Email
+ - generic [ref=f3e35]: "*"
+ - textbox [active] [ref=f3e37]
+ - paragraph [ref=f3e38]:
+ - link "Already have an account? Log in" [ref=f3e39] [cursor=pointer]:
+ - /url: http://acme-fashion.test/account/login
+ - generic [ref=f3e40]:
+ - generic [ref=f3e41]:
+ - generic [ref=f3e42]:
+ - text: First name
+ - generic [ref=f3e43]: "*"
+ - textbox [ref=f3e45]
+ - generic [ref=f3e46]:
+ - generic [ref=f3e47]:
+ - text: Last name
+ - generic [ref=f3e48]: "*"
+ - textbox [ref=f3e50]
+ - generic [ref=f3e51]:
+ - generic [ref=f3e52]:
+ - text: Address line 1
+ - generic [ref=f3e53]: "*"
+ - textbox [ref=f3e55]
+ - generic [ref=f3e56]:
+ - generic [ref=f3e57]: Address line 2
+ - textbox [ref=f3e59]
+ - generic [ref=f3e60]:
+ - generic [ref=f3e61]:
+ - text: City
+ - generic [ref=f3e62]: "*"
+ - textbox [ref=f3e64]
+ - generic [ref=f3e65]:
+ - generic [ref=f3e66]: State / Province
+ - textbox [ref=f3e68]
+ - generic [ref=f3e69]:
+ - generic [ref=f3e70]:
+ - text: Postal code
+ - generic [ref=f3e71]: "*"
+ - textbox [ref=f3e73]
+ - generic [ref=f3e74]:
+ - generic [ref=f3e75]:
+ - text: Country
+ - generic [ref=f3e76]: "*"
+ - combobox [ref=f3e77]:
+ - option "Germany" [selected]
+ - option "Austria"
+ - option "Switzerland"
+ - option "United States"
+ - option "United Kingdom"
+ - option "France"
+ - generic [ref=f3e78]:
+ - generic [ref=f3e79]: Phone
+ - textbox [ref=f3e81]
+ - button "Continue to shipping" [ref=f3e82]
+ - heading "2. Shipping Method" [level=2] [ref=f3e90]
+ - heading "3. Payment Method & Pay" [level=2] [ref=f3e93]
+ - generic [ref=f3e96]:
+ - heading "Order Summary" [level=2] [ref=f3e97]
+ - list [ref=f3e98]:
+ - listitem [ref=f3e99]:
+ - generic [ref=f3e100]: "1"
+ - generic [ref=f3e102]:
+ - paragraph [ref=f3e103]: Classic Cotton T-Shirt
+ - paragraph [ref=f3e104]: S / White
+ - generic [ref=f3e105]: 24.99 EUR
+ - generic [ref=f3e108]:
+ - textbox "Discount code" [ref=f3e110]
+ - button "Apply" [ref=f3e111]
+ - generic [ref=f3e117]:
+ - generic [ref=f3e118]:
+ - term [ref=f3e119]: Subtotal
+ - definition [ref=f3e120]:
+ - generic [ref=f3e121]: 24.99 EUR
+ - generic [ref=f3e123]:
+ - term [ref=f3e124]: Shipping
+ - definition [ref=f3e125]: Calculated at next step
+ - generic [ref=f3e126]:
+ - term [ref=f3e127]: Tax
+ - definition [ref=f3e128]: 0.00 EUR
+ - generic [ref=f3e129]:
+ - term [ref=f3e130]: Total
+ - definition [ref=f3e131]:
+ - generic [ref=f3e132]: 24.99 EUR
+ - contentinfo [ref=f3e134]:
+ - generic [ref=f3e135]:
+ - generic [ref=f3e136]:
+ - generic [ref=f3e137]:
+ - heading "Shop" [level=3] [ref=f3e138]
+ - list [ref=f3e139]:
+ - listitem [ref=f3e140]:
+ - link "About Us" [ref=f3e141] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/about
+ - listitem [ref=f3e142]:
+ - link "FAQ" [ref=f3e143] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/faq
+ - listitem [ref=f3e144]:
+ - link "Shipping & Returns" [ref=f3e145] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/shipping-returns
+ - listitem [ref=f3e146]:
+ - link "Privacy Policy" [ref=f3e147] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/privacy-policy
+ - listitem [ref=f3e148]:
+ - link "Terms of Service" [ref=f3e149] [cursor=pointer]:
+ - /url: http://acme-fashion.test/pages/terms
+ - generic [ref=f3e150]:
+ - heading "Acme Fashion" [level=3] [ref=f3e151]
+ - paragraph [ref=f3e153]: Acme Fashion
+ - generic [ref=f3e154]:
+ - link "Acme Fashion on Facebook" [ref=f3e155] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=f3e158] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=f3e161] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=f3e164] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=f3e167] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=f3e170]:
+ - paragraph [ref=f3e171]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=f3e172]:
+ - generic [ref=f3e173]: Visa
+ - generic [ref=f3e174]: Mastercard
+ - generic [ref=f3e175]: Amex
+ - generic [ref=f3e176]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml b/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml
new file mode 100644
index 00000000..35ed2385
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-29-27-744Z.yml
@@ -0,0 +1,23 @@
+- generic [ref=f5e3]:
+ - link "Shop" [ref=f5e4] [cursor=pointer]:
+ - /url: http://acme-fashion.test
+ - generic [ref=f5e10]:
+ - generic [ref=f5e11]:
+ - generic [ref=f5e12]: Admin sign in
+ - paragraph [ref=f5e13]: Manage your store from one place.
+ - generic [ref=f5e14]:
+ - generic [ref=f5e15]:
+ - generic [ref=f5e16]: Email address
+ - textbox "Email address" [active] [ref=f5e18]
+ - generic [ref=f5e19]:
+ - generic [ref=f5e20]: Password
+ - generic [ref=f5e21]:
+ - textbox "Password" [ref=f5e22]
+ - button "Toggle password visibility" [ref=f5e24]
+ - generic [ref=f5e28]:
+ - generic [ref=f5e29]:
+ - checkbox "Remember me" [ref=f5e30]
+ - generic [ref=f5e32]: Remember me
+ - link "Forgot password?" [ref=f5e33] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/forgot-password
+ - button "Sign in" [ref=f5e34]
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml b/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml
new file mode 100644
index 00000000..51933cf8
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-30-44-478Z.yml
@@ -0,0 +1,108 @@
+- generic [active] [ref=f21e1]:
+ - complementary [ref=f21e2]:
+ - generic [ref=f21e3]:
+ - link "Shop Admin" [ref=f21e4] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - navigation "Admin navigation" [ref=f21e6]:
+ - link "Dashboard" [ref=f21e7] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - link "Products" [ref=f21e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/products
+ - link "Collections" [ref=f21e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/collections
+ - link "Inventory" [ref=f21e16] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/inventory
+ - link "Orders" [ref=f21e19] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/orders
+ - link "Customers" [ref=f21e22] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/customers
+ - link "Discounts" [ref=f21e25] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/discounts
+ - link "Pages" [ref=f21e29] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/pages
+ - link "Navigation" [ref=f21e32] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/navigation
+ - link "Themes" [ref=f21e35] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/themes
+ - link "Analytics" [ref=f21e38] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/analytics
+ - link "Settings" [ref=f21e42] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/settings
+ - link "Apps" [ref=f21e46] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/apps
+ - link "Developers" [ref=f21e49] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/developers
+ - button "Log out" [ref=f21e52]
+ - generic [ref=f21e60]:
+ - banner [ref=f21e61]:
+ - button "Acme Fashion" [ref=f21e64]
+ - generic [ref=f21e68]:
+ - button "Notifications" [ref=f21e69]
+ - button "AU Admin User" [ref=f21e73]:
+ - generic [ref=f21e74]: AU
+ - generic [ref=f21e77]: Admin User
+ - main [ref=f21e81]:
+ - generic [ref=f21e82]:
+ - generic [ref=f21e83]:
+ - generic [ref=f21e84]:
+ - link "Home" [ref=f21e86] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - link "Orders" [ref=f21e90] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/orders
+ - generic [ref=f21e93]: "#1001"
+ - generic [ref=f21e95]:
+ - generic [ref=f21e96]: "#1001"
+ - generic [ref=f21e97]: Paid
+ - generic [ref=f21e98]: Fulfilled
+ - paragraph [ref=f21e99]: Jul 16, 2026 11:27 AM
+ - generic [ref=f21e100]:
+ - button "Create fulfillment" [ref=f21e102]
+ - button "Refund" [ref=f21e104]
+ - generic [ref=f21e105]:
+ - generic [ref=f21e106]:
+ - generic [ref=f21e107]:
+ - generic [ref=f21e108]: Order lines
+ - table [ref=f21e110]:
+ - rowgroup [ref=f21e111]:
+ - row [ref=f21e112]:
+ - columnheader "Product" [ref=f21e113]
+ - columnheader "SKU" [ref=f21e114]
+ - columnheader "Quantity" [ref=f21e115]
+ - columnheader "Total" [ref=f21e116]
+ - rowgroup [ref=f21e117]:
+ - row [ref=f21e118]:
+ - cell "Classic Cotton T-Shirt" [ref=f21e119]
+ - cell "ACME-CTSH-S-WHT" [ref=f21e120]
+ - cell "2" [ref=f21e121]
+ - cell "49.98 EUR" [ref=f21e122]
+ - generic [ref=f21e123]:
+ - generic [ref=f21e124]: Subtotal
+ - generic [ref=f21e125]: "49.98"
+ - generic [ref=f21e126]: Discount
+ - generic [ref=f21e127]: "-0.00"
+ - generic [ref=f21e128]: Shipping
+ - generic [ref=f21e129]: "4.99"
+ - generic [ref=f21e130]: Tax
+ - generic [ref=f21e131]: "7.98"
+ - strong [ref=f21e132]: Total
+ - strong [ref=f21e133]: 54.97 EUR
+ - generic [ref=f21e134]:
+ - generic [ref=f21e135]: Fulfillments
+ - article [ref=f21e136]:
+ - generic [ref=f21e137]:
+ - generic [ref=f21e138]: Pending
+ - button "Mark shipped" [ref=f21e140]
+ - paragraph [ref=f21e146]: DHL TRACK123
+ - complementary [ref=f21e147]:
+ - generic [ref=f21e148]:
+ - generic [ref=f21e149]: Customer
+ - paragraph [ref=f21e150]: John Doe
+ - paragraph [ref=f21e151]: customer@acme.test
+ - link "View customer" [ref=f21e152] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/customers/1
+ - generic [ref=f21e153]:
+ - generic [ref=f21e154]: Shipping address
+ - generic [ref=f21e155]: Hauptstrasse 1 BerlinDE
+ - generic [ref=f21e156]:
+ - generic [ref=f21e157]: Billing address
+ - generic [ref=f21e158]: Hauptstrasse 1 BerlinDE
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml b/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml
new file mode 100644
index 00000000..b6ff9e55
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T11-31-07-430Z.yml
@@ -0,0 +1,108 @@
+- generic [active] [ref=f23e1]:
+ - complementary [ref=f23e2]:
+ - generic [ref=f23e3]:
+ - link "Shop Admin" [ref=f23e4] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - navigation "Admin navigation" [ref=f23e6]:
+ - link "Dashboard" [ref=f23e7] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - link "Products" [ref=f23e10] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/products
+ - link "Collections" [ref=f23e13] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/collections
+ - link "Inventory" [ref=f23e16] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/inventory
+ - link "Orders" [ref=f23e19] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/orders
+ - link "Customers" [ref=f23e22] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/customers
+ - link "Discounts" [ref=f23e25] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/discounts
+ - link "Pages" [ref=f23e29] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/pages
+ - link "Navigation" [ref=f23e32] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/navigation
+ - link "Themes" [ref=f23e35] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/themes
+ - link "Analytics" [ref=f23e38] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/analytics
+ - link "Settings" [ref=f23e42] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/settings
+ - link "Apps" [ref=f23e46] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/apps
+ - link "Developers" [ref=f23e49] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/developers
+ - button "Log out" [ref=f23e52]
+ - generic [ref=f23e60]:
+ - banner [ref=f23e61]:
+ - button "Acme Fashion" [ref=f23e64]
+ - generic [ref=f23e68]:
+ - button "Notifications" [ref=f23e69]
+ - button "AU Admin User" [ref=f23e73]:
+ - generic [ref=f23e74]: AU
+ - generic [ref=f23e77]: Admin User
+ - main [ref=f23e81]:
+ - generic [ref=f23e82]:
+ - generic [ref=f23e83]:
+ - generic [ref=f23e84]:
+ - link "Home" [ref=f23e86] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin
+ - link "Orders" [ref=f23e90] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/orders
+ - generic [ref=f23e93]: "#1001"
+ - generic [ref=f23e95]:
+ - generic [ref=f23e96]: "#1001"
+ - generic [ref=f23e97]: Paid
+ - generic [ref=f23e98]: Fulfilled
+ - paragraph [ref=f23e99]: Jul 16, 2026 11:27 AM
+ - generic [ref=f23e100]:
+ - button "Create fulfillment" [ref=f23e102]
+ - button "Refund" [ref=f23e104]
+ - generic [ref=f23e105]:
+ - generic [ref=f23e106]:
+ - generic [ref=f23e107]:
+ - generic [ref=f23e108]: Order lines
+ - table [ref=f23e110]:
+ - rowgroup [ref=f23e111]:
+ - row [ref=f23e112]:
+ - columnheader "Product" [ref=f23e113]
+ - columnheader "SKU" [ref=f23e114]
+ - columnheader "Quantity" [ref=f23e115]
+ - columnheader "Total" [ref=f23e116]
+ - rowgroup [ref=f23e117]:
+ - row [ref=f23e118]:
+ - cell "Classic Cotton T-Shirt" [ref=f23e119]
+ - cell "ACME-CTSH-S-WHT" [ref=f23e120]
+ - cell "2" [ref=f23e121]
+ - cell "49.98 EUR" [ref=f23e122]
+ - generic [ref=f23e123]:
+ - generic [ref=f23e124]: Subtotal
+ - generic [ref=f23e125]: "49.98"
+ - generic [ref=f23e126]: Discount
+ - generic [ref=f23e127]: "-0.00"
+ - generic [ref=f23e128]: Shipping
+ - generic [ref=f23e129]: "4.99"
+ - generic [ref=f23e130]: Tax
+ - generic [ref=f23e131]: "7.98"
+ - strong [ref=f23e132]: Total
+ - strong [ref=f23e133]: 54.97 EUR
+ - generic [ref=f23e134]:
+ - generic [ref=f23e135]: Fulfillments
+ - article [ref=f23e136]:
+ - generic [ref=f23e137]:
+ - generic [ref=f23e138]: Shipped
+ - button "Mark delivered" [ref=f23e140]
+ - paragraph [ref=f23e146]: DHL TRACK123
+ - complementary [ref=f23e147]:
+ - generic [ref=f23e148]:
+ - generic [ref=f23e149]: Customer
+ - paragraph [ref=f23e150]: John Doe
+ - paragraph [ref=f23e151]: customer@acme.test
+ - link "View customer" [ref=f23e152] [cursor=pointer]:
+ - /url: http://acme-fashion.test/admin/customers/1
+ - generic [ref=f23e153]:
+ - generic [ref=f23e154]: Shipping address
+ - generic [ref=f23e155]: Hauptstrasse 1 BerlinDE
+ - generic [ref=f23e156]:
+ - generic [ref=f23e157]: Billing address
+ - generic [ref=f23e158]: Hauptstrasse 1 BerlinDE
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml b/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml
new file mode 100644
index 00000000..7fded439
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T13-20-19-774Z.yml
@@ -0,0 +1,159 @@
+- generic [active] [ref=e1]:
+ - link "Skip to main content" [ref=e2] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=e3]:
+ - generic [ref=e4]:
+ - link "Acme Fashion" [ref=e5] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev
+ - navigation "Main" [ref=e6]:
+ - link "Home" [ref=e7] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=e8] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals
+ - link "T-Shirts" [ref=e9] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts
+ - link "Pants & Jeans" [ref=e10] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans
+ - link "Sale" [ref=e11] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale
+ - generic [ref=e12]:
+ - link "Search" [ref=e13] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/search
+ - button "Open cart" [ref=e17]
+ - link "Account" [ref=e20] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/account/login
+ - main [ref=e23]:
+ - generic [ref=e24]:
+ - generic [ref=e26]:
+ - heading "Welcome to Acme Fashion" [level=1] [ref=e27]
+ - paragraph [ref=e28]: Discover our latest collections and find something you'll love.
+ - link "Shop now" [ref=e30] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections
+ - generic [ref=e31]:
+ - heading "Shop by Collection" [level=2] [ref=e32]
+ - generic [ref=e33]:
+ - link [ref=e34] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals
+ - paragraph [ref=e36]: New Arrivals
+ - link [ref=e37] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans
+ - paragraph [ref=e39]: Pants & Jeans
+ - link [ref=e40] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale
+ - paragraph [ref=e42]: Sale
+ - link [ref=e43] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts
+ - paragraph [ref=e45]: T-Shirts
+ - generic [ref=e46]:
+ - heading "Featured Products" [level=2] [ref=e47]
+ - generic [ref=e48]:
+ - generic [ref=e49]:
+ - link [ref=e50] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat
+ - heading "Cashmere Overcoat" [level=3] [ref=e55]
+ - generic [ref=e56]: 499.99 EUR
+ - link "Choose options" [ref=e59] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat
+ - generic [ref=e60]:
+ - link [ref=e61] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/gift-card
+ - heading "Gift Card" [level=3] [ref=e66]
+ - generic [ref=e67]: 25.00 EUR
+ - link "Choose options" [ref=e70] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/gift-card
+ - generic [ref=e71]:
+ - link [ref=e72] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/backorder-denim-jacket
+ - heading "Backorder Denim Jacket" [level=3] [ref=e77]
+ - generic [ref=e78]: 99.99 EUR
+ - link "Choose options" [ref=e81] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/backorder-denim-jacket
+ - generic [ref=e82]:
+ - link "Sold out Limited Edition Sneakers" [ref=e83] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/limited-edition-sneakers
+ - generic [ref=e84]: Sold out
+ - heading "Limited Edition Sneakers" [level=3] [ref=e90]
+ - generic [ref=e91]: 159.99 EUR
+ - link "Choose options" [ref=e94] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/limited-edition-sneakers
+ - generic [ref=e95]:
+ - link [ref=e96] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat
+ - heading "Bucket Hat" [level=3] [ref=e101]
+ - generic [ref=e102]: 24.99 EUR
+ - link "Choose options" [ref=e105] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat
+ - generic [ref=e106]:
+ - link [ref=e107] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/canvas-tote-bag
+ - heading "Canvas Tote Bag" [level=3] [ref=e112]
+ - generic [ref=e113]: 19.99 EUR
+ - link "Choose options" [ref=e116] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/canvas-tote-bag
+ - generic [ref=e117]:
+ - link [ref=e118] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wool-scarf
+ - heading "Wool Scarf" [level=3] [ref=e123]
+ - generic [ref=e124]: 29.99 EUR
+ - link "Choose options" [ref=e127] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wool-scarf
+ - generic [ref=e128]:
+ - link "Sale Wide Leg Trousers" [ref=e129] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wide-leg-trousers
+ - generic [ref=e130]: Sale
+ - heading "Wide Leg Trousers" [level=3] [ref=e136]
+ - generic [ref=e138]:
+ - generic [ref=e139]: 49.99 EUR
+ - generic [ref=e140]: 69.99 EUR
+ - generic [ref=e141]: Sale
+ - link "Choose options" [ref=e142] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/wide-leg-trousers
+ - generic [ref=e144]:
+ - heading "Stay in the loop" [level=2] [ref=e145]
+ - paragraph [ref=e146]: Subscribe for exclusive offers and new arrivals.
+ - generic [ref=e147]:
+ - textbox "Email address" [ref=e149]:
+ - /placeholder: Your email address
+ - button "Subscribe" [ref=e150]
+ - contentinfo [ref=e156]:
+ - generic [ref=e157]:
+ - generic [ref=e158]:
+ - generic [ref=e159]:
+ - heading "Shop" [level=3] [ref=e160]
+ - list [ref=e161]:
+ - listitem [ref=e162]:
+ - link "About Us" [ref=e163] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/about
+ - listitem [ref=e164]:
+ - link "FAQ" [ref=e165] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/faq
+ - listitem [ref=e166]:
+ - link "Shipping & Returns" [ref=e167] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/shipping-returns
+ - listitem [ref=e168]:
+ - link "Privacy Policy" [ref=e169] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/privacy-policy
+ - listitem [ref=e170]:
+ - link "Terms of Service" [ref=e171] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/terms
+ - generic [ref=e172]:
+ - heading "Acme Fashion" [level=3] [ref=e173]
+ - paragraph [ref=e175]: Acme Fashion
+ - generic [ref=e176]:
+ - link "Acme Fashion on Facebook" [ref=e177] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=e180] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=e183] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=e186] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=e189] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=e192]:
+ - paragraph [ref=e193]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=e194]:
+ - generic [ref=e195]: Visa
+ - generic [ref=e196]: Mastercard
+ - generic [ref=e197]: Amex
+ - generic [ref=e198]: PayPal
\ No newline at end of file
diff --git a/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml b/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml
new file mode 100644
index 00000000..81a1df25
--- /dev/null
+++ b/.playwright-mcp/page-2026-07-18T13-20-42-149Z.yml
@@ -0,0 +1,197 @@
+- generic [active] [ref=e199]:
+ - link "Skip to main content" [ref=e200] [cursor=pointer]:
+ - /url: "#main-content"
+ - banner [ref=e201]:
+ - generic [ref=e202]:
+ - link "Acme Fashion" [ref=e203] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev
+ - navigation "Main" [ref=e204]:
+ - link "Home" [ref=e205] [cursor=pointer]:
+ - /url: /
+ - link "New Arrivals" [ref=e206] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/new-arrivals
+ - link "T-Shirts" [ref=e207] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/t-shirts
+ - link "Pants & Jeans" [ref=e208] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/pants-jeans
+ - link "Sale" [ref=e209] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections/sale
+ - generic [ref=e210]:
+ - link "Search" [ref=e211] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/search
+ - button "Open cart" [ref=e215]
+ - link "Account" [ref=e218] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/account/login
+ - main [ref=e221]:
+ - generic [ref=e222]:
+ - navigation "Breadcrumb" [ref=e223]:
+ - list [ref=e224]:
+ - listitem [ref=e225]:
+ - link "Home" [ref=e226] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev
+ - generic [ref=e227]: /
+ - listitem [ref=e228]:
+ - link "Collections" [ref=e229] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/collections
+ - generic [ref=e230]: /
+ - listitem [ref=e231]:
+ - generic [ref=e232]: New Arrivals
+ - generic [ref=e233]:
+ - generic [ref=e234]:
+ - heading "New Arrivals" [level=1] [ref=e235]
+ - paragraph [ref=e237]: Discover the latest additions to our store.
+ - generic [ref=e238]:
+ - generic [ref=e239]: Sort by
+ - combobox "Sort by" [ref=e240]:
+ - option "Featured" [selected]
+ - option "Newest"
+ - 'option "Price: Low to High"'
+ - 'option "Price: High to Low"'
+ - generic [ref=e241]:
+ - complementary "Filters" [ref=e242]:
+ - heading "Filters" [level=2] [ref=e244]
+ - generic [ref=e245]:
+ - generic [ref=e246]:
+ - checkbox "In stock only" [ref=e247]
+ - text: In stock only
+ - generic [ref=e248]:
+ - paragraph [ref=e249]: Price
+ - generic [ref=e250]:
+ - spinbutton "Minimum price" [ref=e252]
+ - generic [ref=e254]: "-"
+ - spinbutton "Maximum price" [ref=e256]
+ - generic [ref=e258]:
+ - paragraph [ref=e259]: Product type
+ - generic [ref=e260]:
+ - generic [ref=e261]:
+ - checkbox "Accessories" [ref=e262]
+ - text: Accessories
+ - generic [ref=e263]:
+ - checkbox "Hoodies" [ref=e264]
+ - text: Hoodies
+ - generic [ref=e265]:
+ - checkbox "Jackets" [ref=e266]
+ - text: Jackets
+ - generic [ref=e267]:
+ - checkbox "Pants" [ref=e268]
+ - text: Pants
+ - generic [ref=e269]:
+ - checkbox "Shoes" [ref=e270]
+ - text: Shoes
+ - generic [ref=e271]:
+ - checkbox "T-Shirts" [ref=e272]
+ - text: T-Shirts
+ - generic [ref=e273]:
+ - paragraph [ref=e274]: Vendor
+ - generic [ref=e275]:
+ - generic [ref=e276]:
+ - checkbox "Acme Accessories" [ref=e277]
+ - text: Acme Accessories
+ - generic [ref=e278]:
+ - checkbox "Acme Basics" [ref=e279]
+ - text: Acme Basics
+ - generic [ref=e280]:
+ - checkbox "Acme Denim" [ref=e281]
+ - text: Acme Denim
+ - generic [ref=e282]:
+ - checkbox "Acme Premium" [ref=e283]
+ - text: Acme Premium
+ - generic [ref=e284]:
+ - checkbox "Acme Sport" [ref=e285]
+ - text: Acme Sport
+ - generic [ref=e287]:
+ - generic [ref=e288]:
+ - link [ref=e289] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/classic-cotton-t-shirt
+ - heading "Classic Cotton T-Shirt" [level=3] [ref=e294]
+ - generic [ref=e295]: 24.99 EUR
+ - link "Choose options" [ref=e298] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/classic-cotton-t-shirt
+ - generic [ref=e299]:
+ - link "Sale Premium Slim Fit Jeans" [ref=e300] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/premium-slim-fit-jeans
+ - generic [ref=e301]: Sale
+ - heading "Premium Slim Fit Jeans" [level=3] [ref=e307]
+ - generic [ref=e309]:
+ - generic [ref=e310]: 79.99 EUR
+ - generic [ref=e311]: 99.99 EUR
+ - generic [ref=e312]: Sale
+ - link "Choose options" [ref=e313] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/premium-slim-fit-jeans
+ - generic [ref=e314]:
+ - link [ref=e315] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/organic-hoodie
+ - heading "Organic Hoodie" [level=3] [ref=e320]
+ - generic [ref=e321]: 59.99 EUR
+ - link "Choose options" [ref=e324] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/organic-hoodie
+ - generic [ref=e325]:
+ - link [ref=e326] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/running-sneakers
+ - heading "Running Sneakers" [level=3] [ref=e331]
+ - generic [ref=e332]: 119.99 EUR
+ - link "Choose options" [ref=e335] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/running-sneakers
+ - generic [ref=e336]:
+ - link [ref=e337] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/chino-shorts
+ - heading "Chino Shorts" [level=3] [ref=e342]
+ - generic [ref=e343]: 39.99 EUR
+ - link "Choose options" [ref=e346] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/chino-shorts
+ - generic [ref=e347]:
+ - link [ref=e348] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat
+ - heading "Bucket Hat" [level=3] [ref=e353]
+ - generic [ref=e354]: 24.99 EUR
+ - link "Choose options" [ref=e357] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/bucket-hat
+ - generic [ref=e358]:
+ - link [ref=e359] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat
+ - heading "Cashmere Overcoat" [level=3] [ref=e364]
+ - generic [ref=e365]: 499.99 EUR
+ - link "Choose options" [ref=e368] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/products/cashmere-overcoat
+ - contentinfo [ref=e369]:
+ - generic [ref=e370]:
+ - generic [ref=e371]:
+ - generic [ref=e372]:
+ - heading "Shop" [level=3] [ref=e373]
+ - list [ref=e374]:
+ - listitem [ref=e375]:
+ - link "About Us" [ref=e376] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/about
+ - listitem [ref=e377]:
+ - link "FAQ" [ref=e378] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/faq
+ - listitem [ref=e379]:
+ - link "Shipping & Returns" [ref=e380] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/shipping-returns
+ - listitem [ref=e381]:
+ - link "Privacy Policy" [ref=e382] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/privacy-policy
+ - listitem [ref=e383]:
+ - link "Terms of Service" [ref=e384] [cursor=pointer]:
+ - /url: https://2026-07-18-cursor-grok-4-5.agentic-engineers.dev/pages/terms
+ - generic [ref=e385]:
+ - heading "Acme Fashion" [level=3] [ref=e386]
+ - paragraph [ref=e388]: Acme Fashion
+ - generic [ref=e389]:
+ - link "Acme Fashion on Facebook" [ref=e390] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Instagram" [ref=e393] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on Twitter/X" [ref=e396] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on TikTok" [ref=e399] [cursor=pointer]:
+ - /url: "#"
+ - link "Acme Fashion on YouTube" [ref=e402] [cursor=pointer]:
+ - /url: "#"
+ - generic [ref=e405]:
+ - paragraph [ref=e406]: © 2026 Acme Fashion. All rights reserved.
+ - generic [ref=e407]:
+ - generic [ref=e408]: Visa
+ - generic [ref=e409]: Mastercard
+ - generic [ref=e410]: Amex
+ - generic [ref=e411]: PayPal
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 296f2af0..ba96363f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -23,3 +23,220 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R
- `specs/07-SEEDERS-AND-TEST-DATA.md` - Seeders and test data
- `specs/08-PLAYWRIGHT-E2E-PLAN.md` - E2E browser tests
- `specs/09-IMPLEMENTATION-ROADMAP.md` - Implementation roadmap
+
+===
+
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
+
+## Foundational Context
+
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.4
+- laravel/fortify (FORTIFY) - v1
+- laravel/framework (LARAVEL) - v12
+- laravel/prompts (PROMPTS) - v0
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/boost (BOOST) - v2
+- laravel/mcp (MCP) - v0
+- laravel/pail (PAIL) - v1
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- pestphp/pest (PEST) - v4
+- phpunit/phpunit (PHPUNIT) - v12
+- tailwindcss (TAILWINDCSS) - v4
+
+## Skills Activation
+
+This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
+
+## Conventions
+
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+
+- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+
+- Stick to existing directory structure; don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
+
+## Documentation Files
+
+- You must only create documentation files if explicitly requested by the user.
+
+## Replies
+
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+=== boost rules ===
+
+# Laravel Boost
+
+## Tools
+
+- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
+- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
+- Use `database-schema` to inspect table structure before writing migrations or models.
+- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
+- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
+
+## Searching Documentation (IMPORTANT)
+
+- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
+- Pass a `packages` array to scope results when you know which packages are relevant.
+- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
+- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
+
+### Search Syntax
+
+1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
+2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
+3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
+4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
+
+## Artisan
+
+- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
+- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
+- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
+
+## Tinker
+
+- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
+- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
+ - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
+
+=== php rules ===
+
+# PHP
+
+- Always use curly braces for control structures, even for single-line bodies.
+- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
+- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
+- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
+- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
+- Use array shape type definitions in PHPDoc blocks.
+
+=== deployments rules ===
+
+# Deployment
+
+- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
+
+=== herd rules ===
+
+# Laravel Herd
+
+- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available.
+- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands.
+
+=== tests rules ===
+
+# Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
+
+=== fortify/core rules ===
+
+# Laravel Fortify
+
+- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation.
+- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features.
+
+=== laravel/core rules ===
+
+# Do Things the Laravel Way
+
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Model Creation
+
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
+
+## APIs & Eloquent Resources
+
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+## URL Generation
+
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+## Testing
+
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+## Vite Error
+
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+# Laravel 12
+
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
+- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
+
+## Laravel 12 Structure
+
+- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
+- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+## Database
+
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+=== livewire/core rules ===
+
+# Livewire
+
+- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
+- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
+- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
+
+=== pint/core rules ===
+
+# Laravel Pint Code Formatter
+
+- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
+
+=== pest/core rules ===
+
+## Pest
+
+- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
+- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
+- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
+- Do NOT delete tests without approval.
+
+
diff --git a/CLAUDE.md b/CLAUDE.md
index 7b0f1e95..46203118 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,432 +29,212 @@ The complete specification is in `specs/`. Start with `specs/09-IMPLEMENTATION-R
# Laravel Boost Guidelines
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
## Foundational Context
+
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-- php - 8.4.17
+- php - 8.4
+- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
+- laravel/boost (BOOST) - v2
+- laravel/mcp (MCP) - v0
+- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4
+- phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
+## Skills Activation
+
+This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
## Conventions
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
- Check for existing components to reuse before writing a new one.
## Verification Scripts
-- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
## Application Structure & Architecture
-- Stick to existing directory structure - don't create new base folders without approval.
+
+- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
## Frontend Bundling
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-## Replies
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
## Documentation Files
+
- You must only create documentation files if explicitly requested by the user.
+## Replies
+
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
-## Laravel Boost
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+# Laravel Boost
-## Artisan
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+## Tools
+
+- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
+- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker.
+- Use `database-schema` to inspect table structure before writing migrations or models.
+- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user.
+- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
-## URLs
-- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+## Searching Documentation (IMPORTANT)
-## Tinker / Debugging
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
+- Always use `search-docs` before making code changes. Do not skip this step. It returns version-specific docs based on installed packages automatically.
+- Pass a `packages` array to scope results when you know which packages are relevant.
+- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first.
+- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`.
-## Reading Browser Logs With the `browser-logs` Tool
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
+### Search Syntax
-## Searching Documentation (Critically Important)
-- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
-- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
-- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit".
+2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order.
+3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
+4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
-### Available Search Syntax
-- You can and should pass multiple queries at once. The most relevant results will be returned first.
+## Artisan
+
+- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
+- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
+- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+## Tinker
+- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
+- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
+ - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
-## PHP
+# PHP
-- Always use curly braces for control structures, even if it has one line.
+- Always use curly braces for control structures, even for single-line bodies.
+- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
+- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
+- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`.
+- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
+- Use array shape type definitions in PHPDoc blocks.
-### Constructors
-- Use PHP 8 constructor property promotion in `__construct()`.
- - public function __construct(public GitHub $github) { }
-- Do not allow empty `__construct()` methods with zero parameters.
+=== deployments rules ===
-### Type Declarations
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
+# Deployment
-
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-
+- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
-## Comments
-- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+=== herd rules ===
-## PHPDoc Blocks
-- Add useful array shape type definitions for arrays when appropriate.
+# Laravel Herd
-## Enums
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available.
+- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start `, `herd php:list`). Run `herd list` to discover all available commands.
+=== tests rules ===
-=== herd rules ===
+# Test Enforcement
-## Laravel Herd
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
-- The application is served by Laravel Herd and will be available at: https?://[kebab-case-project-dir].test. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
-- You must not run any commands to make the site available via HTTP(s). It is _always_ available through Laravel Herd.
+=== fortify/core rules ===
+# Laravel Fortify
+
+- Fortify is a headless authentication backend that provides authentication routes and controllers for Laravel applications.
+- IMPORTANT: Always use the `search-docs` tool for detailed Laravel Fortify patterns and documentation.
+- IMPORTANT: Activate `developing-with-fortify` skill when working with Fortify authentication features.
=== laravel/core rules ===
-## Do Things the Laravel Way
+# Do Things the Laravel Way
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `artisan make:class`.
+- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
+- If you're creating a generic PHP class, use `php artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-### Database
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
### Model Creation
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-### APIs & Eloquent Resources
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
-### Controllers & Validation
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
+## APIs & Eloquent Resources
-### Queues
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-### Authentication & Authorization
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+## URL Generation
-### URL Generation
- When generating links to other pages, prefer named routes and the `route()` function.
-### Configuration
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+## Testing
-### Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] ` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-### Vite Error
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+## Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
=== laravel/v12 rules ===
-## Laravel 12
+# Laravel 12
-- Use the `search-docs` tool to get version specific documentation.
+- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-### Laravel 12 Structure
-- No middleware files in `app/Http/Middleware/`.
+## Laravel 12 Structure
+
+- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
+- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
- `bootstrap/providers.php` contains application specific service providers.
-- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
+- The `app/Console/Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+## Database
-### Database
- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
### Models
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-
-=== fluxui-free/core rules ===
-
-## Flux UI Free
-
-- This project is using the free edition of Flux UI. It has full access to the free components and variants, but does not have access to the Pro components.
-- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted, UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
-- You should use Flux UI components when available.
-- Fallback to standard Blade components if Flux is unavailable.
-- If available, use Laravel Boost's `search-docs` tool to get the exact documentation and code snippets available for this project.
-- Flux UI components look like this:
-
-
-
-
-
-
-### Available Components
-This is correct as of Boost installation, but there may be additional components within the codebase.
-
-
-avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, profile, radio, select, separator, switch, text, textarea, tooltip
-
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
=== livewire/core rules ===
-## Livewire Core
-- Use the `search-docs` tool to find exact version specific documentation for how to write Livewire & Livewire tests.
-- Use the `php artisan make:livewire [Posts\CreatePost]` artisan command to create new components
-- State should live on the server, with the UI reflecting it.
-- All Livewire requests hit the Laravel backend, they're like regular HTTP requests. Always validate form data, and run authorization checks in Livewire actions.
-
-## Livewire Best Practices
-- Livewire components require a single root element.
-- Use `wire:loading` and `wire:dirty` for delightful loading states.
-- Add `wire:key` in loops:
-
- ```blade
- @foreach ($items as $item)
-
- {{ $item->name }}
-
- @endforeach
- ```
-
-- Prefer lifecycle hooks like `mount()`, `updatedFoo()`) for initialization and reactive side effects:
-
-
- public function mount(User $user) { $this->user = $user; }
- public function updatedSearch() { $this->resetPage(); }
-
-
-
-## Testing Livewire
-
-
- Livewire::test(Counter::class)
- ->assertSet('count', 0)
- ->call('increment')
- ->assertSet('count', 1)
- ->assertSee(1)
- ->assertStatus(200);
-
-
-
-
- $this->get('/posts/create')
- ->assertSeeLivewire(CreatePost::class);
-
+# Livewire
+- Livewire allow to build dynamic, reactive interfaces in PHP without writing JavaScript.
+- You can use Alpine.js for client-side interactions instead of JavaScript frameworks.
+- Keep state server-side so the UI reflects it. Validate and authorize in actions as you would in HTTP requests.
=== pint/core rules ===
-## Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test`, simply run `vendor/bin/pint` to fix any formatting issues.
+# Laravel Pint Code Formatter
+- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
-### Testing
-- If you need to verify a feature is working, write or update a Unit / Feature test.
-
-### Pest Tests
-- All tests must be written using Pest. Use `php artisan make:test --pest `.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files - these are core to the application.
-- Tests should test all of the happy paths, failure paths, and weird paths.
-- Tests live in the `tests/Feature` and `tests/Unit` directories.
-- Pest tests look and behave like this:
-
-it('is true', function () {
- expect(true)->toBeTrue();
-});
-
-
-### Running Tests
-- Run the minimal number of tests using an appropriate filter before finalizing code edits.
-- To run all tests: `php artisan test`.
-- To run all tests in a file: `php artisan test tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --filter=testName` (recommended after making a change to a related file).
-- When the tests relating to your changes are passing, ask the user if they would like to run the entire test suite to ensure everything is still passing.
-
-### Pest Assertions
-- When asserting status codes on a response, use the specific method like `assertForbidden` and `assertNotFound` instead of using `assertStatus(403)` or similar, e.g.:
-
-it('returns all', function () {
- $response = $this->postJson('/api/docs', []);
-
- $response->assertSuccessful();
-});
-
-
-### Mocking
-- Mocking can be very helpful when appropriate.
-- When mocking, you can use the `Pest\Laravel\mock` Pest function, but always import it via `use function Pest\Laravel\mock;` before using it. Alternatively, you can use `$this->mock()` if existing tests do.
-- You can also create partial mocks using the same import or self method.
-
-### Datasets
-- Use datasets in Pest to simplify tests which have a lot of duplicated data. This is often the case when testing validation rules, so consider going with this solution when writing tests for validation rules.
-
-
-it('has emails', function (string $email) {
- expect($email)->not->toBeEmpty();
-})->with([
- 'james' => 'james@laravel.com',
- 'taylor' => 'taylor@laravel.com',
-]);
-
-
-
-=== pest/v4 rules ===
-
-## Pest 4
-
-- Pest v4 is a huge upgrade to Pest and offers: browser testing, smoke testing, visual regression testing, test sharding, and faster type coverage.
-- Browser testing is incredibly powerful and useful for this project.
-- Browser tests should live in `tests/Browser/`.
-- Use the `search-docs` tool for detailed guidance on utilizing these features.
-
-### Browser Testing
-- You can use Laravel features like `Event::fake()`, `assertAuthenticated()`, and model factories within Pest v4 browser tests, as well as `RefreshDatabase` (when needed) to ensure a clean state for each test.
-- Interact with the page (click, type, scroll, select, submit, drag-and-drop, touch gestures, etc.) when appropriate to complete the test.
-- If requested, test on multiple browsers (Chrome, Firefox, Safari).
-- If requested, test on different devices and viewports (like iPhone 14 Pro, tablets, or custom breakpoints).
-- Switch color schemes (light/dark mode) when appropriate.
-- Take screenshots or pause tests for debugging when appropriate.
-
-### Example Tests
-
-
-it('may reset the password', function () {
- Notification::fake();
-
- $this->actingAs(User::factory()->create());
-
- $page = visit('/sign-in'); // Visit on a real browser...
-
- $page->assertSee('Sign In')
- ->assertNoJavascriptErrors() // or ->assertNoConsoleLogs()
- ->click('Forgot Password?')
- ->fill('email', 'nuno@laravel.com')
- ->click('Send Reset Link')
- ->assertSee('We have emailed your password reset link!')
-
- Notification::assertSent(ResetPassword::class);
-});
-
-
-
+- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
+- The `{name}` argument should not include the test suite directory. Use `php artisan make:test --pest SomeFeatureTest` instead of `php artisan make:test --pest Feature/SomeFeatureTest`.
+- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
+- Do NOT delete tests without approval.
-
-$pages = visit(['/', '/about', '/contact']);
-
-$pages->assertNoJavascriptErrors()->assertNoConsoleLogs();
-
-
-
-=== tailwindcss/core rules ===
-
-## Tailwind Core
-
-- Use Tailwind CSS classes to style HTML, check and use existing tailwind conventions within the project before writing your own.
-- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc..)
-- Think through class placement, order, priority, and defaults - remove redundant classes, add classes to parent or child carefully to limit repetition, group elements logically
-- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
-
-### Spacing
-- When listing items, use gap utilities for spacing, don't use margins.
-
-
-
-
Superior
-
Michigan
-
Erie
-
-
-
-
-### Dark Mode
-- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
-
-
-=== tailwindcss/v4 rules ===
-
-## Tailwind 4
-
-- Always use Tailwind CSS v4 - do not use the deprecated utilities.
-- `corePlugins` is not supported in Tailwind v4.
-- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
-
-
-
-
-### Replaced Utilities
-- Tailwind v4 removed deprecated utilities. Do not use the deprecated option - use the replacement.
-- Opacity values are still numeric.
-
-| Deprecated | Replacement |
-|------------+--------------|
-| bg-opacity-* | bg-black/* |
-| text-opacity-* | text-black/* |
-| border-opacity-* | border-black/* |
-| divide-opacity-* | divide-black/* |
-| ring-opacity-* | ring-black/* |
-| placeholder-opacity-* | placeholder-black/* |
-| flex-shrink-* | shrink-* |
-| flex-grow-* | grow-* |
-| overflow-ellipsis | text-ellipsis |
-| decoration-slice | box-decoration-slice |
-| decoration-clone | box-decoration-clone |
-
-
-=== tests rules ===
-
-## Test Enforcement
-
-- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
-- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test` with a specific filename or filter.
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..0c14741c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+Your mission is to implement an entire shop system based on the specifications im specs/*. You must do in one go without stopping. You might use sub-agents or team mode! You must test everything via Pest (unit, and functional tests). You must also additional simulate user behaviour using the Playwright MPC and confirm that all acceptance criterias are met. If you find bugs, you must fix them. The result is a perfect shop system. All requirements are perfectly implemented. All acceptance criterias are met, tested and confirmed by you.
+
+Continuously keep track of the progress in specs/progress.md Commit your progress after every relevant iteration with a meaningful message.
+
+When implementation is fully done, then make a full review meeting and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting.
+
+Don't re-use any existing implementation in another branch. Build it from scratch.
diff --git a/boost.json b/boost.json
new file mode 100644
index 00000000..ea5c3dc3
--- /dev/null
+++ b/boost.json
@@ -0,0 +1,19 @@
+{
+ "agents": [
+ "opencode",
+ "codex"
+ ],
+ "cloud": false,
+ "guidelines": true,
+ "mcp": true,
+ "nightwatch": false,
+ "sail": false,
+ "skills": [
+ "developing-with-fortify",
+ "laravel-best-practices",
+ "fluxui-development",
+ "livewire-development",
+ "pest-testing",
+ "tailwindcss-development"
+ ]
+}
diff --git a/composer.json b/composer.json
index 1f848aaf..a578e1d1 100644
--- a/composer.json
+++ b/composer.json
@@ -18,7 +18,7 @@
},
"require-dev": {
"fakerphp/faker": "^1.23",
- "laravel/boost": "^1.0",
+ "laravel/boost": "^2.4",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
diff --git a/composer.lock b/composer.lock
index e4255dbd..a48ee02b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "e4aa7ad38dac6834e5ff6bf65b1cdf23",
+ "content-hash": "a73f62d24e65543e17c317a1e9b580fa",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -6877,35 +6877,36 @@
},
{
"name": "laravel/boost",
- "version": "v1.0.18",
+ "version": "v2.4.13",
"source": {
"type": "git",
"url": "https://github.com/laravel/boost.git",
- "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab"
+ "reference": "f55e08f5afa89ac72f23f574175005b67878f466"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab",
- "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/f55e08f5afa89ac72f23f574175005b67878f466",
+ "reference": "f55e08f5afa89ac72f23f574175005b67878f466",
"shasum": ""
},
"require": {
"guzzlehttp/guzzle": "^7.9",
- "illuminate/console": "^10.0|^11.0|^12.0",
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "illuminate/routing": "^10.0|^11.0|^12.0",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "laravel/mcp": "^0.1.0",
- "laravel/prompts": "^0.1.9|^0.3",
- "laravel/roster": "^0.2",
- "php": "^8.1|^8.2"
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
+ "laravel/prompts": "^0.3.10",
+ "laravel/roster": "^0.5.0",
+ "php": "^8.2"
},
"require-dev": {
- "laravel/pint": "^1.14|^1.23",
- "mockery/mockery": "^1.6",
- "orchestra/testbench": "^8.22.0|^9.0|^10.0",
- "pestphp/pest": "^2.0|^3.0",
- "phpstan/phpstan": "^2.0"
+ "laravel/pint": "^1.27.0",
+ "mockery/mockery": "^1.6.12",
+ "orchestra/testbench": "^9.15.0|^10.6|^11.0",
+ "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.1"
},
"type": "library",
"extra": {
@@ -6927,7 +6928,7 @@
"license": [
"MIT"
],
- "description": "Laravel Boost accelerates AI-assisted development to generate high-quality, Laravel-specific code.",
+ "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
"homepage": "https://github.com/laravel/boost",
"keywords": [
"ai",
@@ -6938,41 +6939,48 @@
"issues": "https://github.com/laravel/boost/issues",
"source": "https://github.com/laravel/boost"
},
- "time": "2025-08-16T09:10:03+00:00"
+ "time": "2026-07-17T14:28:57+00:00"
},
{
"name": "laravel/mcp",
- "version": "v0.1.1",
+ "version": "v0.9.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/mcp.git",
- "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713"
+ "reference": "3d365d5db3493c806d190f3404cd7431634ca4e1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713",
- "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/3d365d5db3493c806d190f3404cd7431634ca4e1",
+ "reference": "3d365d5db3493c806d190f3404cd7431634ca4e1",
"shasum": ""
},
"require": {
- "illuminate/console": "^10.0|^11.0|^12.0",
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "illuminate/http": "^10.0|^11.0|^12.0",
- "illuminate/routing": "^10.0|^11.0|^12.0",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "illuminate/validation": "^10.0|^11.0|^12.0",
- "php": "^8.1|^8.2"
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/container": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/http": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/json-schema": "^12.41.1|^13.0",
+ "illuminate/routing": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/support": "^11.45.3|^12.41.1|^13.0",
+ "illuminate/validation": "^11.45.3|^12.41.1|^13.0",
+ "php": "^8.2",
+ "symfony/process": "^7.4.5|^8.0.5"
},
"require-dev": {
- "laravel/pint": "^1.14",
- "orchestra/testbench": "^8.22.0|^9.0|^10.0",
- "phpstan/phpstan": "^2.0"
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "pestphp/pest": "^3.8.5|^4.3.2",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
- "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ "Mcp": "Laravel\\Mcp\\Facades\\Mcp"
},
"providers": [
"Laravel\\Mcp\\Server\\McpServiceProvider"
@@ -6982,8 +6990,6 @@
"autoload": {
"psr-4": {
"Laravel\\Mcp\\": "src/",
- "Workbench\\App\\": "workbench/app/",
- "Laravel\\Mcp\\Tests\\": "tests/",
"Laravel\\Mcp\\Server\\": "src/Server/"
}
},
@@ -6991,10 +6997,15 @@
"license": [
"MIT"
],
- "description": "The easiest way to add MCP servers to your Laravel app.",
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
"homepage": "https://github.com/laravel/mcp",
"keywords": [
- "dev",
"laravel",
"mcp"
],
@@ -7002,7 +7013,7 @@
"issues": "https://github.com/laravel/mcp/issues",
"source": "https://github.com/laravel/mcp"
},
- "time": "2025-08-16T09:50:43+00:00"
+ "time": "2026-07-16T17:16:38+00:00"
},
{
"name": "laravel/pail",
@@ -7153,30 +7164,31 @@
},
{
"name": "laravel/roster",
- "version": "v0.2.2",
+ "version": "v0.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/roster.git",
- "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f"
+ "reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/roster/zipball/67a39bce557a6cb7e7205a2a9d6c464f0e72956f",
- "reference": "67a39bce557a6cb7e7205a2a9d6c464f0e72956f",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
+ "reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
"shasum": ""
},
"require": {
- "illuminate/console": "^10.0|^11.0|^12.0",
- "illuminate/contracts": "^10.0|^11.0|^12.0",
- "illuminate/routing": "^10.0|^11.0|^12.0",
- "illuminate/support": "^10.0|^11.0|^12.0",
- "php": "^8.1|^8.2"
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/routing": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "php": "^8.2",
+ "symfony/yaml": "^7.2|^8.0"
},
"require-dev": {
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
- "orchestra/testbench": "^8.22.0|^9.0|^10.0",
- "pestphp/pest": "^2.0|^3.0",
+ "orchestra/testbench": "^9.0|^10.0|^11.0",
+ "pestphp/pest": "^3.0|^4.1",
"phpstan/phpstan": "^2.0"
},
"type": "library",
@@ -7209,7 +7221,7 @@
"issues": "https://github.com/laravel/roster/issues",
"source": "https://github.com/laravel/roster"
},
- "time": "2025-07-24T12:31:13+00:00"
+ "time": "2026-03-05T07:58:43+00:00"
},
{
"name": "laravel/sail",
@@ -9974,5 +9986,5 @@
"php": "^8.2"
},
"platform-dev": {},
- "plugin-api-version": "2.6.0"
+ "plugin-api-version": "2.9.0"
}
diff --git a/opencode.json b/opencode.json
new file mode 100644
index 00000000..53e16f3d
--- /dev/null
+++ b/opencode.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "laravel-boost": {
+ "type": "local",
+ "enabled": true,
+ "command": [
+ "php",
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
From c002d312e5c3fe186cdb50a6ff0f247f018df6e2 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 10:15:34 +0200
Subject: [PATCH 02/29] Setup: add sanctum + pest-plugin-browser, .env.testing,
remove starter-kit scaffolding
---
.env.testing | 49 +
AGENTS.md | 1 +
app/Actions/Fortify/CreateNewUser.php | 33 -
app/Actions/Fortify/ResetUserPassword.php | 29 -
app/Livewire/Actions/Logout.php | 22 -
app/Livewire/Settings/Appearance.php | 10 -
app/Livewire/Settings/DeleteUserForm.php | 29 -
app/Livewire/Settings/Password.php | 44 -
app/Livewire/Settings/Profile.php | 79 -
app/Livewire/Settings/TwoFactor.php | 182 --
.../Settings/TwoFactor/RecoveryCodes.php | 50 -
app/Providers/FortifyServiceProvider.php | 72 -
bootstrap/providers.php | 1 -
composer.json | 2 +
composer.lock | 1942 +++++++++++++++--
config/fortify.php | 157 --
..._add_two_factor_columns_to_users_table.php | 34 -
.../views/components/action-message.blade.php | 14 -
.../views/components/app-logo-icon.blade.php | 8 -
resources/views/components/app-logo.blade.php | 17 -
.../views/components/auth-header.blade.php | 9 -
.../components/auth-session-status.blade.php | 9 -
.../components/desktop-user-menu.blade.php | 39 -
.../components/placeholder-pattern.blade.php | 12 -
.../components/settings/layout.blade.php | 23 -
resources/views/dashboard.blade.php | 18 -
resources/views/layouts/app.blade.php | 5 -
resources/views/layouts/app/header.blade.php | 78 -
resources/views/layouts/app/sidebar.blade.php | 95 -
resources/views/layouts/auth.blade.php | 3 -
resources/views/layouts/auth/card.blade.php | 26 -
resources/views/layouts/auth/simple.blade.php | 22 -
resources/views/layouts/auth/split.blade.php | 43 -
.../livewire/auth/confirm-password.blade.php | 28 -
.../livewire/auth/forgot-password.blade.php | 31 -
resources/views/livewire/auth/login.blade.php | 59 -
.../views/livewire/auth/register.blade.php | 67 -
.../livewire/auth/reset-password.blade.php | 52 -
.../auth/two-factor-challenge.blade.php | 95 -
.../livewire/auth/verify-email.blade.php | 29 -
.../livewire/settings/appearance.blade.php | 13 -
.../settings/delete-user-form.blade.php | 34 -
.../livewire/settings/password.blade.php | 41 -
.../views/livewire/settings/profile.blade.php | 47 -
.../livewire/settings/two-factor.blade.php | 210 --
.../two-factor/recovery-codes.blade.php | 89 -
resources/views/partials/head.blade.php | 14 -
.../views/partials/settings-heading.blade.php | 5 -
resources/views/welcome.blade.php | 278 ---
routes/settings.php | 30 -
specs/progress.md | 108 +
tests/Feature/Auth/AuthenticationTest.php | 69 -
tests/Feature/Auth/EmailVerificationTest.php | 69 -
.../Feature/Auth/PasswordConfirmationTest.php | 13 -
tests/Feature/Auth/PasswordResetTest.php | 61 -
tests/Feature/Auth/RegistrationTest.php | 23 -
tests/Feature/Auth/TwoFactorChallengeTest.php | 34 -
tests/Feature/DashboardTest.php | 18 -
tests/Feature/ExampleTest.php | 7 -
tests/Feature/Settings/PasswordUpdateTest.php | 42 -
tests/Feature/Settings/ProfileUpdateTest.php | 78 -
.../Settings/TwoFactorAuthenticationTest.php | 72 -
tests/Unit/ExampleTest.php | 5 -
63 files changed, 1948 insertions(+), 2930 deletions(-)
create mode 100644 .env.testing
delete mode 100644 app/Actions/Fortify/CreateNewUser.php
delete mode 100644 app/Actions/Fortify/ResetUserPassword.php
delete mode 100644 app/Livewire/Actions/Logout.php
delete mode 100644 app/Livewire/Settings/Appearance.php
delete mode 100644 app/Livewire/Settings/DeleteUserForm.php
delete mode 100644 app/Livewire/Settings/Password.php
delete mode 100644 app/Livewire/Settings/Profile.php
delete mode 100644 app/Livewire/Settings/TwoFactor.php
delete mode 100644 app/Livewire/Settings/TwoFactor/RecoveryCodes.php
delete mode 100644 app/Providers/FortifyServiceProvider.php
delete mode 100644 config/fortify.php
delete mode 100644 database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php
delete mode 100644 resources/views/components/action-message.blade.php
delete mode 100644 resources/views/components/app-logo-icon.blade.php
delete mode 100644 resources/views/components/app-logo.blade.php
delete mode 100644 resources/views/components/auth-header.blade.php
delete mode 100644 resources/views/components/auth-session-status.blade.php
delete mode 100644 resources/views/components/desktop-user-menu.blade.php
delete mode 100644 resources/views/components/placeholder-pattern.blade.php
delete mode 100644 resources/views/components/settings/layout.blade.php
delete mode 100644 resources/views/dashboard.blade.php
delete mode 100644 resources/views/layouts/app.blade.php
delete mode 100644 resources/views/layouts/app/header.blade.php
delete mode 100644 resources/views/layouts/app/sidebar.blade.php
delete mode 100644 resources/views/layouts/auth.blade.php
delete mode 100644 resources/views/layouts/auth/card.blade.php
delete mode 100644 resources/views/layouts/auth/simple.blade.php
delete mode 100644 resources/views/layouts/auth/split.blade.php
delete mode 100644 resources/views/livewire/auth/confirm-password.blade.php
delete mode 100644 resources/views/livewire/auth/forgot-password.blade.php
delete mode 100644 resources/views/livewire/auth/login.blade.php
delete mode 100644 resources/views/livewire/auth/register.blade.php
delete mode 100644 resources/views/livewire/auth/reset-password.blade.php
delete mode 100644 resources/views/livewire/auth/two-factor-challenge.blade.php
delete mode 100644 resources/views/livewire/auth/verify-email.blade.php
delete mode 100644 resources/views/livewire/settings/appearance.blade.php
delete mode 100644 resources/views/livewire/settings/delete-user-form.blade.php
delete mode 100644 resources/views/livewire/settings/password.blade.php
delete mode 100644 resources/views/livewire/settings/profile.blade.php
delete mode 100644 resources/views/livewire/settings/two-factor.blade.php
delete mode 100644 resources/views/livewire/settings/two-factor/recovery-codes.blade.php
delete mode 100644 resources/views/partials/head.blade.php
delete mode 100644 resources/views/partials/settings-heading.blade.php
delete mode 100644 resources/views/welcome.blade.php
delete mode 100644 routes/settings.php
create mode 100644 specs/progress.md
delete mode 100644 tests/Feature/Auth/AuthenticationTest.php
delete mode 100644 tests/Feature/Auth/EmailVerificationTest.php
delete mode 100644 tests/Feature/Auth/PasswordConfirmationTest.php
delete mode 100644 tests/Feature/Auth/PasswordResetTest.php
delete mode 100644 tests/Feature/Auth/RegistrationTest.php
delete mode 100644 tests/Feature/Auth/TwoFactorChallengeTest.php
delete mode 100644 tests/Feature/DashboardTest.php
delete mode 100644 tests/Feature/ExampleTest.php
delete mode 100644 tests/Feature/Settings/PasswordUpdateTest.php
delete mode 100644 tests/Feature/Settings/ProfileUpdateTest.php
delete mode 100644 tests/Feature/Settings/TwoFactorAuthenticationTest.php
delete mode 100644 tests/Unit/ExampleTest.php
diff --git a/.env.testing b/.env.testing
new file mode 100644
index 00000000..796f660f
--- /dev/null
+++ b/.env.testing
@@ -0,0 +1,49 @@
+APP_NAME=Shop
+APP_ENV=testing
+APP_KEY=base64:2EWqBvgCtrFAYOtd+JT2CcIKngs8qnNhdUT8Q8yX6p8=
+APP_DEBUG=true
+APP_URL=http://acme-fashion.test
+FRONTEND_URL=http://acme-fashion.test
+
+APP_LOCALE=en
+APP_FALLBACK_LOCALE=en
+APP_FAKER_LOCALE=en_US
+
+APP_MAINTENANCE_DRIVER=file
+
+BCRYPT_ROUNDS=4
+
+LOG_CHANNEL=json
+LOG_LEVEL=debug
+LOG_DEPRECATIONS_CHANNEL=null
+
+DB_CONNECTION=sqlite
+DB_DATABASE=/Users/fabianwesner/Herd/shop/database/testing.sqlite
+DB_FOREIGN_KEYS=true
+
+SESSION_DRIVER=array
+SESSION_LIFETIME=120
+SESSION_ENCRYPT=false
+SESSION_COOKIE=shop_session
+SESSION_PATH=/
+SESSION_DOMAIN=null
+SESSION_SECURE_COOKIE=false
+SESSION_HTTP_ONLY=true
+SESSION_SAME_SITE=lax
+
+BROADCAST_CONNECTION=log
+FILESYSTEM_DISK=local
+QUEUE_CONNECTION=sync
+CACHE_STORE=array
+
+MAIL_MAILER=array
+MAIL_FROM_ADDRESS="hello@shop.test"
+MAIL_FROM_NAME="${APP_NAME}"
+
+SANCTUM_STATEFUL_DOMAINS=shop.test,acme-fashion.test,admin.acme-fashion.test,acme-electronics.test,127.0.0.1,localhost
+SANCTUM_TOKEN_PREFIX=shop_
+SANCTUM_EXPIRATION=525600
+
+PAYMENT_PROVIDER=mock
+
+VITE_APP_NAME="${APP_NAME}"
diff --git a/AGENTS.md b/AGENTS.md
index ba96363f..66899279 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -41,6 +41,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
- laravel/boost (BOOST) - v2
diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php
deleted file mode 100644
index 3c7c00c8..00000000
--- a/app/Actions/Fortify/CreateNewUser.php
+++ /dev/null
@@ -1,33 +0,0 @@
- $input
- */
- public function create(array $input): User
- {
- Validator::make($input, [
- ...$this->profileRules(),
- 'password' => $this->passwordRules(),
- ])->validate();
-
- return User::create([
- 'name' => $input['name'],
- 'email' => $input['email'],
- 'password' => $input['password'],
- ]);
- }
-}
diff --git a/app/Actions/Fortify/ResetUserPassword.php b/app/Actions/Fortify/ResetUserPassword.php
deleted file mode 100644
index 8fda5ddd..00000000
--- a/app/Actions/Fortify/ResetUserPassword.php
+++ /dev/null
@@ -1,29 +0,0 @@
- $input
- */
- public function reset(User $user, array $input): void
- {
- Validator::make($input, [
- 'password' => $this->passwordRules(),
- ])->validate();
-
- $user->forceFill([
- 'password' => $input['password'],
- ])->save();
- }
-}
diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php
deleted file mode 100644
index 45993bb8..00000000
--- a/app/Livewire/Actions/Logout.php
+++ /dev/null
@@ -1,22 +0,0 @@
-logout();
-
- Session::invalidate();
- Session::regenerateToken();
-
- return redirect('/');
- }
-}
diff --git a/app/Livewire/Settings/Appearance.php b/app/Livewire/Settings/Appearance.php
deleted file mode 100644
index 7e87193e..00000000
--- a/app/Livewire/Settings/Appearance.php
+++ /dev/null
@@ -1,10 +0,0 @@
-validate([
- 'password' => $this->currentPasswordRules(),
- ]);
-
- tap(Auth::user(), $logout(...))->delete();
-
- $this->redirect('/', navigate: true);
- }
-}
diff --git a/app/Livewire/Settings/Password.php b/app/Livewire/Settings/Password.php
deleted file mode 100644
index 613abebe..00000000
--- a/app/Livewire/Settings/Password.php
+++ /dev/null
@@ -1,44 +0,0 @@
-validate([
- 'current_password' => $this->currentPasswordRules(),
- 'password' => $this->passwordRules(),
- ]);
- } catch (ValidationException $e) {
- $this->reset('current_password', 'password', 'password_confirmation');
-
- throw $e;
- }
-
- Auth::user()->update([
- 'password' => $validated['password'],
- ]);
-
- $this->reset('current_password', 'password', 'password_confirmation');
-
- $this->dispatch('password-updated');
- }
-}
diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php
deleted file mode 100644
index bfecd6cf..00000000
--- a/app/Livewire/Settings/Profile.php
+++ /dev/null
@@ -1,79 +0,0 @@
-name = Auth::user()->name;
- $this->email = Auth::user()->email;
- }
-
- /**
- * Update the profile information for the currently authenticated user.
- */
- public function updateProfileInformation(): void
- {
- $user = Auth::user();
-
- $validated = $this->validate($this->profileRules($user->id));
-
- $user->fill($validated);
-
- if ($user->isDirty('email')) {
- $user->email_verified_at = null;
- }
-
- $user->save();
-
- $this->dispatch('profile-updated', name: $user->name);
- }
-
- /**
- * Send an email verification notification to the current user.
- */
- public function resendVerificationNotification(): void
- {
- $user = Auth::user();
-
- if ($user->hasVerifiedEmail()) {
- $this->redirectIntended(default: route('dashboard', absolute: false));
-
- return;
- }
-
- $user->sendEmailVerificationNotification();
-
- Session::flash('status', 'verification-link-sent');
- }
-
- #[Computed]
- public function hasUnverifiedEmail(): bool
- {
- return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
- }
-
- #[Computed]
- public function showDeleteUser(): bool
- {
- return ! Auth::user() instanceof MustVerifyEmail
- || (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
- }
-}
diff --git a/app/Livewire/Settings/TwoFactor.php b/app/Livewire/Settings/TwoFactor.php
deleted file mode 100644
index a1641b56..00000000
--- a/app/Livewire/Settings/TwoFactor.php
+++ /dev/null
@@ -1,182 +0,0 @@
-user()->two_factor_confirmed_at)) {
- $disableTwoFactorAuthentication(auth()->user());
- }
-
- $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
- $this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
- }
-
- /**
- * Enable two-factor authentication for the user.
- */
- public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
- {
- $enableTwoFactorAuthentication(auth()->user());
-
- if (! $this->requiresConfirmation) {
- $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
- }
-
- $this->loadSetupData();
-
- $this->showModal = true;
- }
-
- /**
- * Load the two-factor authentication setup data for the user.
- */
- private function loadSetupData(): void
- {
- $user = auth()->user();
-
- try {
- $this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
- $this->manualSetupKey = decrypt($user->two_factor_secret);
- } catch (Exception) {
- $this->addError('setupData', 'Failed to fetch setup data.');
-
- $this->reset('qrCodeSvg', 'manualSetupKey');
- }
- }
-
- /**
- * Show the two-factor verification step if necessary.
- */
- public function showVerificationIfNecessary(): void
- {
- if ($this->requiresConfirmation) {
- $this->showVerificationStep = true;
-
- $this->resetErrorBag();
-
- return;
- }
-
- $this->closeModal();
- }
-
- /**
- * Confirm two-factor authentication for the user.
- */
- public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
- {
- $this->validate();
-
- $confirmTwoFactorAuthentication(auth()->user(), $this->code);
-
- $this->closeModal();
-
- $this->twoFactorEnabled = true;
- }
-
- /**
- * Reset two-factor verification state.
- */
- public function resetVerification(): void
- {
- $this->reset('code', 'showVerificationStep');
-
- $this->resetErrorBag();
- }
-
- /**
- * Disable two-factor authentication for the user.
- */
- public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
- {
- $disableTwoFactorAuthentication(auth()->user());
-
- $this->twoFactorEnabled = false;
- }
-
- /**
- * Close the two-factor authentication modal.
- */
- public function closeModal(): void
- {
- $this->reset(
- 'code',
- 'manualSetupKey',
- 'qrCodeSvg',
- 'showModal',
- 'showVerificationStep',
- );
-
- $this->resetErrorBag();
-
- if (! $this->requiresConfirmation) {
- $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
- }
- }
-
- /**
- * Get the current modal configuration state.
- */
- public function getModalConfigProperty(): array
- {
- if ($this->twoFactorEnabled) {
- return [
- 'title' => __('Two-Factor Authentication Enabled'),
- 'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
- 'buttonText' => __('Close'),
- ];
- }
-
- if ($this->showVerificationStep) {
- return [
- 'title' => __('Verify Authentication Code'),
- 'description' => __('Enter the 6-digit code from your authenticator app.'),
- 'buttonText' => __('Continue'),
- ];
- }
-
- return [
- 'title' => __('Enable Two-Factor Authentication'),
- 'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
- 'buttonText' => __('Continue'),
- ];
- }
-}
diff --git a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php b/app/Livewire/Settings/TwoFactor/RecoveryCodes.php
deleted file mode 100644
index 7352d80f..00000000
--- a/app/Livewire/Settings/TwoFactor/RecoveryCodes.php
+++ /dev/null
@@ -1,50 +0,0 @@
-loadRecoveryCodes();
- }
-
- /**
- * Generate new recovery codes for the user.
- */
- public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
- {
- $generateNewRecoveryCodes(auth()->user());
-
- $this->loadRecoveryCodes();
- }
-
- /**
- * Load the recovery codes for the user.
- */
- private function loadRecoveryCodes(): void
- {
- $user = auth()->user();
-
- if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
- try {
- $this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
- } catch (Exception) {
- $this->addError('recoveryCodes', 'Failed to load recovery codes');
-
- $this->recoveryCodes = [];
- }
- }
- }
-}
diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php
deleted file mode 100644
index 44e57aa0..00000000
--- a/app/Providers/FortifyServiceProvider.php
+++ /dev/null
@@ -1,72 +0,0 @@
-configureActions();
- $this->configureViews();
- $this->configureRateLimiting();
- }
-
- /**
- * Configure Fortify actions.
- */
- private function configureActions(): void
- {
- Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
- Fortify::createUsersUsing(CreateNewUser::class);
- }
-
- /**
- * Configure Fortify views.
- */
- private function configureViews(): void
- {
- Fortify::loginView(fn () => view('livewire.auth.login'));
- Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email'));
- Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge'));
- Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password'));
- Fortify::registerView(fn () => view('livewire.auth.register'));
- Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password'));
- Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password'));
- }
-
- /**
- * Configure rate limiting.
- */
- private function configureRateLimiting(): void
- {
- RateLimiter::for('two-factor', function (Request $request) {
- return Limit::perMinute(5)->by($request->session()->get('login.id'));
- });
-
- RateLimiter::for('login', function (Request $request) {
- $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
-
- return Limit::perMinute(5)->by($throttleKey);
- });
- }
-}
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index 0ad9c573..38b258d1 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -2,5 +2,4 @@
return [
App\Providers\AppServiceProvider::class,
- App\Providers\FortifyServiceProvider::class,
];
diff --git a/composer.json b/composer.json
index a578e1d1..72e094db 100644
--- a/composer.json
+++ b/composer.json
@@ -12,6 +12,7 @@
"php": "^8.2",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
+ "laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.9.0",
"livewire/livewire": "^4.0"
@@ -25,6 +26,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.3",
+ "pestphp/pest-plugin-browser": "^4.3",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {
diff --git a/composer.lock b/composer.lock
index a48ee02b..f3dac5e6 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "a73f62d24e65543e17c317a1e9b580fa",
+ "content-hash": "842bb7ef40dbf5886b406e01f112bcc6",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -1501,6 +1501,69 @@
},
"time": "2026-02-06T12:17:10+00:00"
},
+ {
+ "name": "laravel/sanctum",
+ "version": "v4.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/sanctum.git",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "php": "^8.2",
+ "symfony/console": "^7.0|^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Sanctum\\SanctumServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Sanctum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
+ "keywords": [
+ "auth",
+ "laravel",
+ "sanctum"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/sanctum/issues",
+ "source": "https://github.com/laravel/sanctum"
+ },
+ "time": "2026-04-30T11:46:25+00:00"
+ },
{
"name": "laravel/serializable-closure",
"version": "v2.0.9",
@@ -6429,56 +6492,37 @@
],
"packages-dev": [
{
- "name": "brianium/paratest",
- "version": "v7.17.0",
+ "name": "amphp/amp",
+ "version": "v3.1.2",
"source": {
"type": "git",
- "url": "https://github.com/paratestphp/paratest.git",
- "reference": "53cb90a6aa3ef3840458781600628ade058a18b9"
+ "url": "https://github.com/amphp/amp.git",
+ "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9",
- "reference": "53cb90a6aa3ef3840458781600628ade058a18b9",
+ "url": "https://api.github.com/repos/amphp/amp/zipball/2f3ebed5a4f663968a0590dbb7654a8b32cb63cb",
+ "reference": "2f3ebed5a4f663968a0590dbb7654a8b32cb63cb",
"shasum": ""
},
"require": {
- "ext-dom": "*",
- "ext-pcre": "*",
- "ext-reflection": "*",
- "ext-simplexml": "*",
- "fidry/cpu-core-counter": "^1.3.0",
- "jean85/pretty-package-versions": "^2.1.1",
- "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
- "phpunit/php-code-coverage": "^12.5.2",
- "phpunit/php-file-iterator": "^6",
- "phpunit/php-timer": "^8",
- "phpunit/phpunit": "^12.5.8",
- "sebastian/environment": "^8.0.3",
- "symfony/console": "^7.3.4 || ^8.0.0",
- "symfony/process": "^7.3.4 || ^8.0.0"
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
},
"require-dev": {
- "doctrine/coding-standard": "^14.0.0",
- "ext-pcntl": "*",
- "ext-pcov": "*",
- "ext-posix": "*",
- "phpstan/phpstan": "^2.1.38",
- "phpstan/phpstan-deprecation-rules": "^2.0.3",
- "phpstan/phpstan-phpunit": "^2.0.12",
- "phpstan/phpstan-strict-rules": "^2.0.8",
- "symfony/filesystem": "^7.3.2 || ^8.0.0"
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
},
- "bin": [
- "bin/paratest",
- "bin/paratest_for_phpstorm"
- ],
"type": "library",
"autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Future/functions.php",
+ "src/Internal/functions.php"
+ ],
"psr-4": {
- "ParaTest\\": [
- "src/"
- ]
+ "Amp\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -6487,128 +6531,153 @@
],
"authors": [
{
- "name": "Brian Scaturro",
- "email": "scaturrob@gmail.com",
- "role": "Developer"
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
},
{
- "name": "Filippo Tessarotto",
- "email": "zoeslam@gmail.com",
- "role": "Developer"
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
}
],
- "description": "Parallel testing for PHP",
- "homepage": "https://github.com/paratestphp/paratest",
+ "description": "A non-blocking concurrency framework for PHP applications.",
+ "homepage": "https://amphp.org/amp",
"keywords": [
- "concurrent",
- "parallel",
- "phpunit",
- "testing"
+ "async",
+ "asynchronous",
+ "awaitable",
+ "concurrency",
+ "event",
+ "event-loop",
+ "future",
+ "non-blocking",
+ "promise"
],
"support": {
- "issues": "https://github.com/paratestphp/paratest/issues",
- "source": "https://github.com/paratestphp/paratest/tree/v7.17.0"
+ "issues": "https://github.com/amphp/amp/issues",
+ "source": "https://github.com/amphp/amp/tree/v3.1.2"
},
"funding": [
{
- "url": "https://github.com/sponsors/Slamdunk",
+ "url": "https://github.com/amphp",
"type": "github"
- },
- {
- "url": "https://paypal.me/filippotessarotto",
- "type": "paypal"
}
],
- "time": "2026-02-05T09:14:44+00:00"
+ "time": "2026-06-21T13:59:44+00:00"
},
{
- "name": "doctrine/deprecations",
- "version": "1.1.6",
+ "name": "amphp/byte-stream",
+ "version": "v2.1.2",
"source": {
"type": "git",
- "url": "https://github.com/doctrine/deprecations.git",
- "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+ "url": "https://github.com/amphp/byte-stream.git",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
- "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "url": "https://api.github.com/repos/amphp/byte-stream/zipball/55a6bd071aec26fa2a3e002618c20c35e3df1b46",
+ "reference": "55a6bd071aec26fa2a3e002618c20c35e3df1b46",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0"
- },
- "conflict": {
- "phpunit/phpunit": "<=7.5 || >=14"
+ "amphp/amp": "^3",
+ "amphp/parser": "^1.1",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2.3"
},
"require-dev": {
- "doctrine/coding-standard": "^9 || ^12 || ^14",
- "phpstan/phpstan": "1.4.10 || 2.1.30",
- "phpstan/phpstan-phpunit": "^1.0 || ^2",
- "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
- "psr/log": "^1 || ^2 || ^3"
- },
- "suggest": {
- "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.22.1"
},
"type": "library",
"autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
"psr-4": {
- "Doctrine\\Deprecations\\": "src"
+ "Amp\\ByteStream\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
- "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
- "homepage": "https://www.doctrine-project.org/",
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A stream abstraction to make working with non-blocking I/O simple.",
+ "homepage": "https://amphp.org/byte-stream",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "non-blocking",
+ "stream"
+ ],
"support": {
- "issues": "https://github.com/doctrine/deprecations/issues",
- "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+ "issues": "https://github.com/amphp/byte-stream/issues",
+ "source": "https://github.com/amphp/byte-stream/tree/v2.1.2"
},
- "time": "2026-02-07T07:09:04+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-03-16T17:10:27+00:00"
},
{
- "name": "fakerphp/faker",
- "version": "v1.24.1",
+ "name": "amphp/cache",
+ "version": "v2.0.1",
"source": {
"type": "git",
- "url": "https://github.com/FakerPHP/Faker.git",
- "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
+ "url": "https://github.com/amphp/cache.git",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
- "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c",
+ "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c",
"shasum": ""
},
"require": {
- "php": "^7.4 || ^8.0",
- "psr/container": "^1.0 || ^2.0",
- "symfony/deprecation-contracts": "^2.2 || ^3.0"
- },
- "conflict": {
- "fzaninotto/faker": "*"
+ "amphp/amp": "^3",
+ "amphp/serialization": "^1",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
},
"require-dev": {
- "bamarni/composer-bin-plugin": "^1.4.1",
- "doctrine/persistence": "^1.3 || ^2.0",
- "ext-intl": "*",
- "phpunit/phpunit": "^9.5.26",
- "symfony/phpunit-bridge": "^5.4.16"
- },
- "suggest": {
- "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
- "ext-curl": "Required by Faker\\Provider\\Image to download images.",
- "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
- "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
- "ext-mbstring": "Required for multibyte Unicode string functionality."
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
},
"type": "library",
"autoload": {
"psr-4": {
- "Faker\\": "src/Faker/"
+ "Amp\\Cache\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -6617,53 +6686,71 @@
],
"authors": [
{
- "name": "François Zaninotto"
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
}
],
- "description": "Faker is a PHP library that generates fake data for you.",
- "keywords": [
- "data",
- "faker",
- "fixtures"
- ],
+ "description": "A fiber-aware cache API based on Amp and Revolt.",
+ "homepage": "https://amphp.org/cache",
"support": {
- "issues": "https://github.com/FakerPHP/Faker/issues",
- "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
+ "issues": "https://github.com/amphp/cache/issues",
+ "source": "https://github.com/amphp/cache/tree/v2.0.1"
},
- "time": "2024-11-21T13:46:39+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-04-19T03:38:06+00:00"
},
{
- "name": "fidry/cpu-core-counter",
- "version": "1.3.0",
+ "name": "amphp/dns",
+ "version": "v2.4.0",
"source": {
"type": "git",
- "url": "https://github.com/theofidry/cpu-core-counter.git",
- "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ "url": "https://github.com/amphp/dns.git",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
- "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "url": "https://api.github.com/repos/amphp/dns/zipball/78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
+ "reference": "78eb3db5fc69bf2fc0cb503c4fcba667bc223c71",
"shasum": ""
},
"require": {
- "php": "^7.2 || ^8.0"
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/parser": "^1",
+ "amphp/process": "^2",
+ "daverandom/libdns": "^2.0.2",
+ "ext-filter": "*",
+ "ext-json": "*",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
},
"require-dev": {
- "fidry/makefile": "^0.2.0",
- "fidry/php-cs-fixer-config": "^1.1.2",
- "phpstan/extension-installer": "^1.2.0",
- "phpstan/phpstan": "^2.0",
- "phpstan/phpstan-deprecation-rules": "^2.0.0",
- "phpstan/phpstan-phpunit": "^2.0",
- "phpstan/phpstan-strict-rules": "^2.0",
- "phpunit/phpunit": "^8.5.31 || ^9.5.26",
- "webmozarts/strict-phpunit": "^7.5"
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.20"
},
"type": "library",
"autoload": {
+ "files": [
+ "src/functions.php"
+ ],
"psr-4": {
- "Fidry\\CpuCoreCounter\\": "src/"
+ "Amp\\Dns\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -6672,51 +6759,1301 @@
],
"authors": [
{
- "name": "Théo FIDRY",
- "email": "theo.fidry@gmail.com"
+ "name": "Chris Wright",
+ "email": "addr@daverandom.com"
+ },
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
}
],
- "description": "Tiny utility to get the number of CPU cores.",
+ "description": "Async DNS resolution for Amp.",
+ "homepage": "https://github.com/amphp/dns",
"keywords": [
- "CPU",
- "core"
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "dns",
+ "resolve"
],
"support": {
- "issues": "https://github.com/theofidry/cpu-core-counter/issues",
- "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ "issues": "https://github.com/amphp/dns/issues",
+ "source": "https://github.com/amphp/dns/tree/v2.4.0"
},
"funding": [
{
- "url": "https://github.com/theofidry",
+ "url": "https://github.com/amphp",
"type": "github"
}
],
- "time": "2025-08-14T07:29:31+00:00"
+ "time": "2025-01-19T15:43:40+00:00"
},
{
- "name": "filp/whoops",
- "version": "2.18.4",
+ "name": "amphp/hpack",
+ "version": "v3.2.2",
"source": {
"type": "git",
- "url": "https://github.com/filp/whoops.git",
- "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
+ "url": "https://github.com/amphp/hpack.git",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
- "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "url": "https://api.github.com/repos/amphp/hpack/zipball/291da27078e7e149a9bad4d08ff05bf7d81c89f4",
+ "reference": "291da27078e7e149a9bad4d08ff05bf7d81c89f4",
"shasum": ""
},
"require": {
- "php": "^7.1 || ^8.0",
- "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ "php": ">=7.1"
},
"require-dev": {
- "mockery/mockery": "^1.0",
- "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
- "symfony/var-dumper": "^4.0 || ^5.0"
+ "amphp/php-cs-fixer-config": "^2",
+ "http2jp/hpack-test-case": "^1",
+ "nikic/php-fuzzer": "^0.0.11",
+ "phpunit/phpunit": "^7 | ^8 | ^9"
},
- "suggest": {
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "HTTP/2 HPack implementation.",
+ "homepage": "https://github.com/amphp/hpack",
+ "keywords": [
+ "headers",
+ "hpack",
+ "http-2"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/hpack/issues",
+ "source": "https://github.com/amphp/hpack/tree/v3.2.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-03T19:28:59+00:00"
+ },
+ {
+ "name": "amphp/http",
+ "version": "v2.1.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http.git",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http/zipball/3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "reference": "3680d80bd38b5d6f3c2cef2214ca6dd6cef26588",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/hpack": "^3",
+ "amphp/parser": "^1.1",
+ "league/uri-components": "^2.4.2 | ^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "league/uri": "^6.8 | ^7.1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.26.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/constants.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "Basic HTTP primitives which can be shared by servers and clients.",
+ "support": {
+ "issues": "https://github.com/amphp/http/issues",
+ "source": "https://github.com/amphp/http/tree/v2.1.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-11-23T14:57:26+00:00"
+ },
+ {
+ "name": "amphp/http-client",
+ "version": "v5.3.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-client.git",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-client/zipball/ca155026acafa74a612d776a97202d53077fee86",
+ "reference": "ca155026acafa74a612d776a97202d53077fee86",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "amphp/sync": "^2",
+ "league/uri": "^7",
+ "league/uri-components": "^7",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "revolt/event-loop": "^1"
+ },
+ "conflict": {
+ "amphp/file": "<3 | >=5"
+ },
+ "require-dev": {
+ "amphp/file": "^3 | ^4",
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "ext-json": "*",
+ "kelunik/link-header-rfc5988": "^1",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "amphp/file": "Required for file request bodies and HTTP archive logging",
+ "ext-json": "Required for logging HTTP archives",
+ "ext-zlib": "Allows using compression for response bodies."
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "An advanced async HTTP client library for PHP, enabling efficient, non-blocking, and concurrent requests and responses.",
+ "homepage": "https://amphp.org/http-client",
+ "keywords": [
+ "async",
+ "client",
+ "concurrent",
+ "http",
+ "non-blocking",
+ "rest"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-client/issues",
+ "source": "https://github.com/amphp/http-client/tree/v5.3.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-15T23:29:38+00:00"
+ },
+ {
+ "name": "amphp/http-server",
+ "version": "v3.4.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/http-server.git",
+ "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/http-server/zipball/8a971bf92cf8cf2bc511f37a75b39126d5305315",
+ "reference": "8a971bf92cf8cf2bc511f37a75b39126d5305315",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/cache": "^2",
+ "amphp/hpack": "^3",
+ "amphp/http": "^2",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2.1",
+ "amphp/sync": "^2.2",
+ "league/uri": "^7.1",
+ "league/uri-interfaces": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1 | ^2",
+ "psr/log": "^1 | ^2 | ^3",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-client": "^5",
+ "amphp/log": "^2",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "league/uri-components": "^7.1",
+ "monolog/monolog": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "suggest": {
+ "ext-zlib": "Allows GZip compression of response bodies"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/Driver/functions.php",
+ "src/Middleware/functions.php",
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Http\\Server\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@php.net"
+ },
+ {
+ "name": "Bob Weinand"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ }
+ ],
+ "description": "A non-blocking HTTP application server for PHP based on Amp.",
+ "homepage": "https://github.com/amphp/http-server",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "server"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/http-server/issues",
+ "source": "https://github.com/amphp/http-server/tree/v3.4.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-27T10:31:48+00:00"
+ },
+ {
+ "name": "amphp/parser",
+ "version": "v1.1.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/parser.git",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/parser/zipball/3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "reference": "3cf1f8b32a0171d4b1bed93d25617637a77cded7",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.4"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Parser\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A generator parser to make streaming parsers simple.",
+ "homepage": "https://github.com/amphp/parser",
+ "keywords": [
+ "async",
+ "non-blocking",
+ "parser",
+ "stream"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/parser/issues",
+ "source": "https://github.com/amphp/parser/tree/v1.1.1"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-03-21T19:16:53+00:00"
+ },
+ {
+ "name": "amphp/pipeline",
+ "version": "v1.2.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/pipeline.git",
+ "reference": "10941bf38de5c585aa2407b2ec4265d806d4eef2"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/pipeline/zipball/10941bf38de5c585aa2407b2ec4265d806d4eef2",
+ "reference": "10941bf38de5c585aa2407b2ec4265d806d4eef2",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Amp\\Pipeline\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Asynchronous iterators and operators.",
+ "homepage": "https://amphp.org/pipeline",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "io",
+ "iterator",
+ "non-blocking"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/pipeline/issues",
+ "source": "https://github.com/amphp/pipeline/tree/v1.2.6"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-06-27T16:15:40+00:00"
+ },
+ {
+ "name": "amphp/process",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/process.git",
+ "reference": "583959df17d00304ad7b0b32285373f985935643"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/process/zipball/583959df17d00304ad7b0b32285373f985935643",
+ "reference": "583959df17d00304ad7b0b32285373f985935643",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/sync": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Process\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "A fiber-aware process manager based on Amp and Revolt.",
+ "homepage": "https://amphp.org/process",
+ "support": {
+ "issues": "https://github.com/amphp/process/issues",
+ "source": "https://github.com/amphp/process/tree/v2.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-05-31T15:11:55+00:00"
+ },
+ {
+ "name": "amphp/serialization",
+ "version": "v1.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/serialization.git",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/serialization/zipball/fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "reference": "fdf2834d78cebb0205fb2672676c1b1eb84371f0",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.4"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "ext-json": "*",
+ "ext-zlib": "*",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Serialization\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Serialization tools for IPC and data storage in PHP.",
+ "homepage": "https://github.com/amphp/serialization",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "serialization",
+ "serialize"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/serialization/issues",
+ "source": "https://github.com/amphp/serialization/tree/v1.1.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-05T15:59:53+00:00"
+ },
+ {
+ "name": "amphp/socket",
+ "version": "v2.4.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/socket.git",
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/socket/zipball/dadb63c5d3179fd83803e29dfeac27350e619314",
+ "reference": "dadb63c5d3179fd83803e29dfeac27350e619314",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/dns": "^2",
+ "ext-openssl": "*",
+ "kelunik/certificate": "^1.1",
+ "league/uri": "^7",
+ "league/uri-interfaces": "^7",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/process": "^2",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php",
+ "src/Internal/functions.php",
+ "src/SocketAddress/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Socket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Daniel Lowrey",
+ "email": "rdlowrey@gmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/socket",
+ "keywords": [
+ "amp",
+ "async",
+ "encryption",
+ "non-blocking",
+ "sockets",
+ "tcp",
+ "tls"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/socket/issues",
+ "source": "https://github.com/amphp/socket/tree/v2.4.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2026-04-19T15:09:56+00:00"
+ },
+ {
+ "name": "amphp/sync",
+ "version": "v2.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/sync.git",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/pipeline": "^1",
+ "amphp/serialization": "^1",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1 || ^0.2"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "5.23"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Sync\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Stephen Coakley",
+ "email": "me@stephencoakley.com"
+ }
+ ],
+ "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.",
+ "homepage": "https://github.com/amphp/sync",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "mutex",
+ "semaphore",
+ "synchronization"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/sync/issues",
+ "source": "https://github.com/amphp/sync/tree/v2.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-08-03T19:31:26+00:00"
+ },
+ {
+ "name": "amphp/websocket",
+ "version": "v2.0.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket.git",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket/zipball/963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "reference": "963904b6a883c4b62d9222d1d9749814fac96a3b",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2",
+ "amphp/parser": "^1",
+ "amphp/pipeline": "^1",
+ "amphp/socket": "^2",
+ "php": ">=8.1",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "^5.18"
+ },
+ "suggest": {
+ "ext-zlib": "Required for compression"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ },
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ }
+ ],
+ "description": "Shared code for websocket servers and clients.",
+ "homepage": "https://github.com/amphp/websocket",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket/issues",
+ "source": "https://github.com/amphp/websocket/tree/v2.0.4"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2024-10-28T21:28:45+00:00"
+ },
+ {
+ "name": "amphp/websocket-client",
+ "version": "v2.0.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/amphp/websocket-client.git",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/amphp/websocket-client/zipball/dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "reference": "dc033fdce0af56295a23f63ac4f579b34d470d6c",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3",
+ "amphp/byte-stream": "^2.1",
+ "amphp/http": "^2.1",
+ "amphp/http-client": "^5",
+ "amphp/socket": "^2.2",
+ "amphp/websocket": "^2",
+ "league/uri": "^7.1",
+ "php": ">=8.1",
+ "psr/http-message": "^1|^2",
+ "revolt/event-loop": "^1"
+ },
+ "require-dev": {
+ "amphp/http-server": "^3",
+ "amphp/php-cs-fixer-config": "^2",
+ "amphp/phpunit-util": "^3",
+ "amphp/websocket-server": "^3|^4",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "~5.26.1",
+ "psr/log": "^1"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "Amp\\Websocket\\Client\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Bob Weinand",
+ "email": "bobwei9@hotmail.com"
+ },
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Async WebSocket client for PHP based on Amp.",
+ "keywords": [
+ "amp",
+ "amphp",
+ "async",
+ "client",
+ "http",
+ "non-blocking",
+ "websocket"
+ ],
+ "support": {
+ "issues": "https://github.com/amphp/websocket-client/issues",
+ "source": "https://github.com/amphp/websocket-client/tree/v2.0.2"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/amphp",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-24T17:25:34+00:00"
+ },
+ {
+ "name": "brianium/paratest",
+ "version": "v7.17.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/paratestphp/paratest.git",
+ "reference": "53cb90a6aa3ef3840458781600628ade058a18b9"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9",
+ "reference": "53cb90a6aa3ef3840458781600628ade058a18b9",
+ "shasum": ""
+ },
+ "require": {
+ "ext-dom": "*",
+ "ext-pcre": "*",
+ "ext-reflection": "*",
+ "ext-simplexml": "*",
+ "fidry/cpu-core-counter": "^1.3.0",
+ "jean85/pretty-package-versions": "^2.1.1",
+ "php": "~8.3.0 || ~8.4.0 || ~8.5.0",
+ "phpunit/php-code-coverage": "^12.5.2",
+ "phpunit/php-file-iterator": "^6",
+ "phpunit/php-timer": "^8",
+ "phpunit/phpunit": "^12.5.8",
+ "sebastian/environment": "^8.0.3",
+ "symfony/console": "^7.3.4 || ^8.0.0",
+ "symfony/process": "^7.3.4 || ^8.0.0"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^14.0.0",
+ "ext-pcntl": "*",
+ "ext-pcov": "*",
+ "ext-posix": "*",
+ "phpstan/phpstan": "^2.1.38",
+ "phpstan/phpstan-deprecation-rules": "^2.0.3",
+ "phpstan/phpstan-phpunit": "^2.0.12",
+ "phpstan/phpstan-strict-rules": "^2.0.8",
+ "symfony/filesystem": "^7.3.2 || ^8.0.0"
+ },
+ "bin": [
+ "bin/paratest",
+ "bin/paratest_for_phpstorm"
+ ],
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "ParaTest\\": [
+ "src/"
+ ]
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Brian Scaturro",
+ "email": "scaturrob@gmail.com",
+ "role": "Developer"
+ },
+ {
+ "name": "Filippo Tessarotto",
+ "email": "zoeslam@gmail.com",
+ "role": "Developer"
+ }
+ ],
+ "description": "Parallel testing for PHP",
+ "homepage": "https://github.com/paratestphp/paratest",
+ "keywords": [
+ "concurrent",
+ "parallel",
+ "phpunit",
+ "testing"
+ ],
+ "support": {
+ "issues": "https://github.com/paratestphp/paratest/issues",
+ "source": "https://github.com/paratestphp/paratest/tree/v7.17.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/sponsors/Slamdunk",
+ "type": "github"
+ },
+ {
+ "url": "https://paypal.me/filippotessarotto",
+ "type": "paypal"
+ }
+ ],
+ "time": "2026-02-05T09:14:44+00:00"
+ },
+ {
+ "name": "daverandom/libdns",
+ "version": "v2.1.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DaveRandom/LibDNS.git",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a",
+ "shasum": ""
+ },
+ "require": {
+ "ext-ctype": "*",
+ "php": ">=7.1"
+ },
+ "suggest": {
+ "ext-intl": "Required for IDN support"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/functions.php"
+ ],
+ "psr-4": {
+ "LibDNS\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "DNS protocol implementation written in pure PHP",
+ "keywords": [
+ "dns"
+ ],
+ "support": {
+ "issues": "https://github.com/DaveRandom/LibDNS/issues",
+ "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0"
+ },
+ "time": "2024-04-12T12:12:48+00:00"
+ },
+ {
+ "name": "doctrine/deprecations",
+ "version": "1.1.6",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/doctrine/deprecations.git",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0"
+ },
+ "conflict": {
+ "phpunit/phpunit": "<=7.5 || >=14"
+ },
+ "require-dev": {
+ "doctrine/coding-standard": "^9 || ^12 || ^14",
+ "phpstan/phpstan": "1.4.10 || 2.1.30",
+ "phpstan/phpstan-phpunit": "^1.0 || ^2",
+ "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+ "psr/log": "^1 || ^2 || ^3"
+ },
+ "suggest": {
+ "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Doctrine\\Deprecations\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+ "homepage": "https://www.doctrine-project.org/",
+ "support": {
+ "issues": "https://github.com/doctrine/deprecations/issues",
+ "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+ },
+ "time": "2026-02-07T07:09:04+00:00"
+ },
+ {
+ "name": "fakerphp/faker",
+ "version": "v1.24.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/FakerPHP/Faker.git",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 || ^8.0",
+ "psr/container": "^1.0 || ^2.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "conflict": {
+ "fzaninotto/faker": "*"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.4.1",
+ "doctrine/persistence": "^1.3 || ^2.0",
+ "ext-intl": "*",
+ "phpunit/phpunit": "^9.5.26",
+ "symfony/phpunit-bridge": "^5.4.16"
+ },
+ "suggest": {
+ "doctrine/orm": "Required to use Faker\\ORM\\Doctrine",
+ "ext-curl": "Required by Faker\\Provider\\Image to download images.",
+ "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.",
+ "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.",
+ "ext-mbstring": "Required for multibyte Unicode string functionality."
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Faker\\": "src/Faker/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "François Zaninotto"
+ }
+ ],
+ "description": "Faker is a PHP library that generates fake data for you.",
+ "keywords": [
+ "data",
+ "faker",
+ "fixtures"
+ ],
+ "support": {
+ "issues": "https://github.com/FakerPHP/Faker/issues",
+ "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1"
+ },
+ "time": "2024-11-21T13:46:39+00:00"
+ },
+ {
+ "name": "fidry/cpu-core-counter",
+ "version": "1.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/theofidry/cpu-core-counter.git",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+ "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2 || ^8.0"
+ },
+ "require-dev": {
+ "fidry/makefile": "^0.2.0",
+ "fidry/php-cs-fixer-config": "^1.1.2",
+ "phpstan/extension-installer": "^1.2.0",
+ "phpstan/phpstan": "^2.0",
+ "phpstan/phpstan-deprecation-rules": "^2.0.0",
+ "phpstan/phpstan-phpunit": "^2.0",
+ "phpstan/phpstan-strict-rules": "^2.0",
+ "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+ "webmozarts/strict-phpunit": "^7.5"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "Fidry\\CpuCoreCounter\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Théo FIDRY",
+ "email": "theo.fidry@gmail.com"
+ }
+ ],
+ "description": "Tiny utility to get the number of CPU cores.",
+ "keywords": [
+ "CPU",
+ "core"
+ ],
+ "support": {
+ "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+ "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/theofidry",
+ "type": "github"
+ }
+ ],
+ "time": "2025-08-14T07:29:31+00:00"
+ },
+ {
+ "name": "filp/whoops",
+ "version": "2.18.4",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/filp/whoops.git",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.1 || ^8.0",
+ "psr/log": "^1.0.1 || ^2.0 || ^3.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3",
+ "symfony/var-dumper": "^4.0 || ^5.0"
+ },
+ "suggest": {
"symfony/var-dumper": "Pretty print complex values better with var-dumper available",
"whoops/soap": "Formats errors as SOAP responses"
},
@@ -6875,6 +8212,64 @@
},
"time": "2025-03-19T14:43:43+00:00"
},
+ {
+ "name": "kelunik/certificate",
+ "version": "v1.1.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/kelunik/certificate.git",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-openssl": "*",
+ "php": ">=7.0"
+ },
+ "require-dev": {
+ "amphp/php-cs-fixer-config": "^2",
+ "phpunit/phpunit": "^6 | 7 | ^8 | ^9"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Kelunik\\Certificate\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Access certificate details and transform between different formats.",
+ "keywords": [
+ "DER",
+ "certificate",
+ "certificates",
+ "openssl",
+ "pem",
+ "x509"
+ ],
+ "support": {
+ "issues": "https://github.com/kelunik/certificate/issues",
+ "source": "https://github.com/kelunik/certificate/tree/v1.1.3"
+ },
+ "time": "2023-02-03T21:26:53+00:00"
+ },
{
"name": "laravel/boost",
"version": "v2.4.13",
@@ -7286,6 +8681,90 @@
},
"time": "2026-02-06T12:16:02+00:00"
},
+ {
+ "name": "league/uri-components",
+ "version": "7.8.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/thephpleague/uri-components.git",
+ "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/thephpleague/uri-components/zipball/8b5ffcebcc0842b76eb80964795bd56a8333b2ba",
+ "reference": "8b5ffcebcc0842b76eb80964795bd56a8333b2ba",
+ "shasum": ""
+ },
+ "require": {
+ "league/uri": "^7.8",
+ "php": "^8.1"
+ },
+ "suggest": {
+ "ext-bcmath": "to improve IPV4 host parsing",
+ "ext-fileinfo": "to create Data URI from file contennts",
+ "ext-gmp": "to improve IPV4 host parsing",
+ "ext-intl": "to handle IDN host with the best performance",
+ "ext-mbstring": "to use the sorting algorithm of URLSearchParams",
+ "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain",
+ "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP",
+ "php-64bit": "to improve IPV4 host parsing",
+ "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification",
+ "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "7.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "League\\Uri\\": ""
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ignace Nyamagana Butera",
+ "email": "nyamsprod@gmail.com",
+ "homepage": "https://nyamsprod.com"
+ }
+ ],
+ "description": "URI components manipulation library",
+ "homepage": "http://uri.thephpleague.com",
+ "keywords": [
+ "authority",
+ "components",
+ "fragment",
+ "host",
+ "middleware",
+ "modifier",
+ "path",
+ "port",
+ "query",
+ "rfc3986",
+ "scheme",
+ "uri",
+ "url",
+ "userinfo"
+ ],
+ "support": {
+ "docs": "https://uri.thephpleague.com",
+ "forum": "https://thephpleague.slack.com",
+ "issues": "https://github.com/thephpleague/uri-src/issues",
+ "source": "https://github.com/thephpleague/uri-components/tree/7.8.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/nyamsprod",
+ "type": "github"
+ }
+ ],
+ "time": "2026-01-14T17:24:56+00:00"
+ },
{
"name": "mockery/mockery",
"version": "1.6.12",
@@ -7784,6 +9263,89 @@
],
"time": "2025-08-20T13:10:51+00:00"
},
+ {
+ "name": "pestphp/pest-plugin-browser",
+ "version": "v4.3.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/pestphp/pest-plugin-browser.git",
+ "reference": "48bc408033281974952a6b296592cef3b920a2db"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/pestphp/pest-plugin-browser/zipball/48bc408033281974952a6b296592cef3b920a2db",
+ "reference": "48bc408033281974952a6b296592cef3b920a2db",
+ "shasum": ""
+ },
+ "require": {
+ "amphp/amp": "^3.1.1",
+ "amphp/http-server": "^3.4.4",
+ "amphp/websocket-client": "^2.0.2",
+ "ext-sockets": "*",
+ "pestphp/pest": "^4.3.2",
+ "pestphp/pest-plugin": "^4.0.0",
+ "php": "^8.3",
+ "symfony/process": "^7.4.5|^8.0.5"
+ },
+ "require-dev": {
+ "ext-pcntl": "*",
+ "ext-posix": "*",
+ "livewire/livewire": "^3.7.10",
+ "nunomaduro/collision": "^8.9.0",
+ "orchestra/testbench": "^10.9.0",
+ "pestphp/pest-dev-tools": "^4.1.0",
+ "pestphp/pest-plugin-laravel": "^4.0",
+ "pestphp/pest-plugin-type-coverage": "^4.0.3"
+ },
+ "type": "library",
+ "extra": {
+ "pest": {
+ "plugins": [
+ "Pest\\Browser\\Plugin"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/Autoload.php"
+ ],
+ "psr-4": {
+ "Pest\\Browser\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Pest plugin to test browser interactions",
+ "keywords": [
+ "browser",
+ "framework",
+ "pest",
+ "php",
+ "test",
+ "testing",
+ "unit"
+ ],
+ "support": {
+ "source": "https://github.com/pestphp/pest-plugin-browser/tree/v4.3.0"
+ },
+ "funding": [
+ {
+ "url": "https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=66BYDWAT92N6L",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/nunomaduro",
+ "type": "github"
+ },
+ {
+ "url": "https://www.patreon.com/nunomaduro",
+ "type": "patreon"
+ }
+ ],
+ "time": "2026-02-17T14:54:40+00:00"
+ },
{
"name": "pestphp/pest-plugin-laravel",
"version": "v4.0.0",
@@ -8781,6 +10343,78 @@
],
"time": "2026-01-27T06:12:29+00:00"
},
+ {
+ "name": "revolt/event-loop",
+ "version": "v1.0.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/revoltphp/event-loop.git",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/44061cf513e53c6200372fc935ac42271566295d",
+ "reference": "44061cf513e53c6200372fc935ac42271566295d",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "ext-json": "*",
+ "jetbrains/phpstorm-stubs": "^2019.3",
+ "phpunit/phpunit": "^9",
+ "psalm/phar": "6.16.*"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Revolt\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Aaron Piotrowski",
+ "email": "aaron@trowski.com"
+ },
+ {
+ "name": "Cees-Jan Kiewiet",
+ "email": "ceesjank@gmail.com"
+ },
+ {
+ "name": "Christian Lück",
+ "email": "christian@clue.engineering"
+ },
+ {
+ "name": "Niklas Keller",
+ "email": "me@kelunik.com"
+ }
+ ],
+ "description": "Rock-solid event loop for concurrent PHP applications.",
+ "keywords": [
+ "async",
+ "asynchronous",
+ "concurrency",
+ "event",
+ "event-loop",
+ "non-blocking",
+ "scheduler"
+ ],
+ "support": {
+ "issues": "https://github.com/revoltphp/event-loop/issues",
+ "source": "https://github.com/revoltphp/event-loop/tree/v1.0.9"
+ },
+ "time": "2026-05-16T17:55:38+00:00"
+ },
{
"name": "sebastian/cli-parser",
"version": "4.2.0",
diff --git a/config/fortify.php b/config/fortify.php
deleted file mode 100644
index ce67e2c3..00000000
--- a/config/fortify.php
+++ /dev/null
@@ -1,157 +0,0 @@
- 'web',
-
- /*
- |--------------------------------------------------------------------------
- | Fortify Password Broker
- |--------------------------------------------------------------------------
- |
- | Here you may specify which password broker Fortify can use when a user
- | is resetting their password. This configured value should match one
- | of your password brokers setup in your "auth" configuration file.
- |
- */
-
- 'passwords' => 'users',
-
- /*
- |--------------------------------------------------------------------------
- | Username / Email
- |--------------------------------------------------------------------------
- |
- | This value defines which model attribute should be considered as your
- | application's "username" field. Typically, this might be the email
- | address of the users but you are free to change this value here.
- |
- | Out of the box, Fortify expects forgot password and reset password
- | requests to have a field named 'email'. If the application uses
- | another name for the field you may define it below as needed.
- |
- */
-
- 'username' => 'email',
-
- 'email' => 'email',
-
- /*
- |--------------------------------------------------------------------------
- | Lowercase Usernames
- |--------------------------------------------------------------------------
- |
- | This value defines whether usernames should be lowercased before saving
- | them in the database, as some database system string fields are case
- | sensitive. You may disable this for your application if necessary.
- |
- */
-
- 'lowercase_usernames' => true,
-
- /*
- |--------------------------------------------------------------------------
- | Home Path
- |--------------------------------------------------------------------------
- |
- | Here you may configure the path where users will get redirected during
- | authentication or password reset when the operations are successful
- | and the user is authenticated. You are free to change this value.
- |
- */
-
- 'home' => '/dashboard',
-
- /*
- |--------------------------------------------------------------------------
- | Fortify Routes Prefix / Subdomain
- |--------------------------------------------------------------------------
- |
- | Here you may specify which prefix Fortify will assign to all the routes
- | that it registers with the application. If necessary, you may change
- | subdomain under which all of the Fortify routes will be available.
- |
- */
-
- 'prefix' => '',
-
- 'domain' => null,
-
- /*
- |--------------------------------------------------------------------------
- | Fortify Routes Middleware
- |--------------------------------------------------------------------------
- |
- | Here you may specify which middleware Fortify will assign to the routes
- | that it registers with the application. If necessary, you may change
- | these middleware but typically this provided default is preferred.
- |
- */
-
- 'middleware' => ['web'],
-
- /*
- |--------------------------------------------------------------------------
- | Rate Limiting
- |--------------------------------------------------------------------------
- |
- | By default, Fortify will throttle logins to five requests per minute for
- | every email and IP address combination. However, if you would like to
- | specify a custom rate limiter to call then you may specify it here.
- |
- */
-
- 'limiters' => [
- 'login' => 'login',
- 'two-factor' => 'two-factor',
- ],
-
- /*
- |--------------------------------------------------------------------------
- | Register View Routes
- |--------------------------------------------------------------------------
- |
- | Here you may specify if the routes returning views should be disabled as
- | you may not need them when building your own application. This may be
- | especially true if you're writing a custom single-page application.
- |
- */
-
- 'views' => true,
-
- /*
- |--------------------------------------------------------------------------
- | Features
- |--------------------------------------------------------------------------
- |
- | Some of the Fortify features are optional. You may disable the features
- | by removing them from this array. You're free to only remove some of
- | these features, or you can even remove all of these if you need to.
- |
- */
-
- 'features' => [
- Features::registration(),
- Features::resetPasswords(),
- Features::emailVerification(),
- Features::twoFactorAuthentication([
- 'confirm' => true,
- 'confirmPassword' => true,
- // 'window' => 0
- ]),
- ],
-
-];
diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php
deleted file mode 100644
index 187d974d..00000000
--- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php
+++ /dev/null
@@ -1,34 +0,0 @@
-text('two_factor_secret')->after('password')->nullable();
- $table->text('two_factor_recovery_codes')->after('two_factor_secret')->nullable();
- $table->timestamp('two_factor_confirmed_at')->after('two_factor_recovery_codes')->nullable();
- });
- }
-
- /**
- * Reverse the migrations.
- */
- public function down(): void
- {
- Schema::table('users', function (Blueprint $table) {
- $table->dropColumn([
- 'two_factor_secret',
- 'two_factor_recovery_codes',
- 'two_factor_confirmed_at',
- ]);
- });
- }
-};
diff --git a/resources/views/components/action-message.blade.php b/resources/views/components/action-message.blade.php
deleted file mode 100644
index d313ee61..00000000
--- a/resources/views/components/action-message.blade.php
+++ /dev/null
@@ -1,14 +0,0 @@
-@props([
- 'on',
-])
-
-merge(['class' => 'text-sm']) }}
->
- {{ $slot->isEmpty() ? __('Saved.') : $slot }}
-
diff --git a/resources/views/components/app-logo-icon.blade.php b/resources/views/components/app-logo-icon.blade.php
deleted file mode 100644
index 0adc3a2a..00000000
--- a/resources/views/components/app-logo-icon.blade.php
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
diff --git a/resources/views/components/app-logo.blade.php b/resources/views/components/app-logo.blade.php
deleted file mode 100644
index 26e8f686..00000000
--- a/resources/views/components/app-logo.blade.php
+++ /dev/null
@@ -1,17 +0,0 @@
-@props([
- 'sidebar' => false,
-])
-
-@if($sidebar)
-
-
-
-
-
-@else
-
-
-
-
-
-@endif
diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php
deleted file mode 100644
index e596a3f3..00000000
--- a/resources/views/components/auth-header.blade.php
+++ /dev/null
@@ -1,9 +0,0 @@
-@props([
- 'title',
- 'description',
-])
-
-
- {{ $title }}
- {{ $description }}
-
diff --git a/resources/views/components/auth-session-status.blade.php b/resources/views/components/auth-session-status.blade.php
deleted file mode 100644
index 98e00112..00000000
--- a/resources/views/components/auth-session-status.blade.php
+++ /dev/null
@@ -1,9 +0,0 @@
-@props([
- 'status',
-])
-
-@if ($status)
- merge(['class' => 'font-medium text-sm text-green-600']) }}>
- {{ $status }}
-
-@endif
diff --git a/resources/views/components/desktop-user-menu.blade.php b/resources/views/components/desktop-user-menu.blade.php
deleted file mode 100644
index 5b386c5c..00000000
--- a/resources/views/components/desktop-user-menu.blade.php
+++ /dev/null
@@ -1,39 +0,0 @@
-
- only('name') }}
- :initials="auth()->user()->initials()"
- icon:trailing="chevrons-up-down"
- data-test="sidebar-menu-button"
- />
-
-
-
-
-
- {{ auth()->user()->name }}
- {{ auth()->user()->email }}
-
-
-
-
-
- {{ __('Settings') }}
-
-
-
-
-
diff --git a/resources/views/components/placeholder-pattern.blade.php b/resources/views/components/placeholder-pattern.blade.php
deleted file mode 100644
index 8a434f04..00000000
--- a/resources/views/components/placeholder-pattern.blade.php
+++ /dev/null
@@ -1,12 +0,0 @@
-@props([
- 'id' => uniqid(),
-])
-
-
-
-
-
-
-
-
-
diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php
deleted file mode 100644
index 17c7a0a8..00000000
--- a/resources/views/components/settings/layout.blade.php
+++ /dev/null
@@ -1,23 +0,0 @@
-
-
-
- {{ __('Profile') }}
- {{ __('Password') }}
- @if (Laravel\Fortify\Features::canManageTwoFactorAuthentication())
- {{ __('Two-Factor Auth') }}
- @endif
- {{ __('Appearance') }}
-
-
-
-
-
-
-
{{ $heading ?? '' }}
-
{{ $subheading ?? '' }}
-
-
- {{ $slot }}
-
-
-
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php
deleted file mode 100644
index 8f08c05d..00000000
--- a/resources/views/dashboard.blade.php
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php
deleted file mode 100644
index 037dd1bd..00000000
--- a/resources/views/layouts/app.blade.php
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
- {{ $slot }}
-
-
diff --git a/resources/views/layouts/app/header.blade.php b/resources/views/layouts/app/header.blade.php
deleted file mode 100644
index e1f84d92..00000000
--- a/resources/views/layouts/app/header.blade.php
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
- @include('partials.head')
-
-
-
-
-
-
-
-
-
- {{ __('Dashboard') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Dashboard') }}
-
-
-
-
-
-
-
-
- {{ __('Repository') }}
-
-
- {{ __('Documentation') }}
-
-
-
-
- {{ $slot }}
-
- @fluxScripts
-
-
diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php
deleted file mode 100644
index ea25506b..00000000
--- a/resources/views/layouts/app/sidebar.blade.php
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
- @include('partials.head')
-
-
-
-
-
-
-
-
-
-
-
- {{ __('Dashboard') }}
-
-
-
-
-
-
-
-
- {{ __('Repository') }}
-
-
-
- {{ __('Documentation') }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ auth()->user()->name }}
- {{ auth()->user()->email }}
-
-
-
-
-
-
-
-
-
- {{ __('Settings') }}
-
-
-
-
-
-
-
-
-
-
- {{ $slot }}
-
- @fluxScripts
-
-
diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php
deleted file mode 100644
index 71500919..00000000
--- a/resources/views/layouts/auth.blade.php
+++ /dev/null
@@ -1,3 +0,0 @@
-
- {{ $slot }}
-
diff --git a/resources/views/layouts/auth/card.blade.php b/resources/views/layouts/auth/card.blade.php
deleted file mode 100644
index db947161..00000000
--- a/resources/views/layouts/auth/card.blade.php
+++ /dev/null
@@ -1,26 +0,0 @@
-
-
-
- @include('partials.head')
-
-
-
- @fluxScripts
-
-
diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php
deleted file mode 100644
index 6e0d9093..00000000
--- a/resources/views/layouts/auth/simple.blade.php
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
- @include('partials.head')
-
-
-
- @fluxScripts
-
-
diff --git a/resources/views/layouts/auth/split.blade.php b/resources/views/layouts/auth/split.blade.php
deleted file mode 100644
index 4e9788bd..00000000
--- a/resources/views/layouts/auth/split.blade.php
+++ /dev/null
@@ -1,43 +0,0 @@
-
-
-
- @include('partials.head')
-
-
-
- @fluxScripts
-
-
diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php
deleted file mode 100644
index 09b2fbc1..00000000
--- a/resources/views/livewire/auth/confirm-password.blade.php
+++ /dev/null
@@ -1,28 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php
deleted file mode 100644
index 4af48477..00000000
--- a/resources/views/livewire/auth/forgot-password.blade.php
+++ /dev/null
@@ -1,31 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('Or, return to') }}
- {{ __('log in') }}
-
-
-
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php
deleted file mode 100644
index 0fee9de2..00000000
--- a/resources/views/livewire/auth/login.blade.php
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
- @if (Route::has('register'))
-
- {{ __('Don\'t have an account?') }}
- {{ __('Sign up') }}
-
- @endif
-
-
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php
deleted file mode 100644
index 381ec0ac..00000000
--- a/resources/views/livewire/auth/register.blade.php
+++ /dev/null
@@ -1,67 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- {{ __('Already have an account?') }}
- {{ __('Log in') }}
-
-
-
diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php
deleted file mode 100644
index 1b6bd538..00000000
--- a/resources/views/livewire/auth/reset-password.blade.php
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
diff --git a/resources/views/livewire/auth/two-factor-challenge.blade.php b/resources/views/livewire/auth/two-factor-challenge.blade.php
deleted file mode 100644
index bfba986d..00000000
--- a/resources/views/livewire/auth/two-factor-challenge.blade.php
+++ /dev/null
@@ -1,95 +0,0 @@
-
-
-
diff --git a/resources/views/livewire/auth/verify-email.blade.php b/resources/views/livewire/auth/verify-email.blade.php
deleted file mode 100644
index 252d7bc4..00000000
--- a/resources/views/livewire/auth/verify-email.blade.php
+++ /dev/null
@@ -1,29 +0,0 @@
-
-
-
- {{ __('Please verify your email address by clicking on the link we just emailed to you.') }}
-
-
- @if (session('status') == 'verification-link-sent')
-
- {{ __('A new verification link has been sent to the email address you provided during registration.') }}
-
- @endif
-
-
-
-
-
-
-
-
diff --git a/resources/views/livewire/settings/appearance.blade.php b/resources/views/livewire/settings/appearance.blade.php
deleted file mode 100644
index 3272f6e5..00000000
--- a/resources/views/livewire/settings/appearance.blade.php
+++ /dev/null
@@ -1,13 +0,0 @@
-
- @include('partials.settings-heading')
-
- {{ __('Appearance Settings') }}
-
-
-
- {{ __('Light') }}
- {{ __('Dark') }}
- {{ __('System') }}
-
-
-
diff --git a/resources/views/livewire/settings/delete-user-form.blade.php b/resources/views/livewire/settings/delete-user-form.blade.php
deleted file mode 100644
index f8a0d4ea..00000000
--- a/resources/views/livewire/settings/delete-user-form.blade.php
+++ /dev/null
@@ -1,34 +0,0 @@
-
-
- {{ __('Delete account') }}
- {{ __('Delete your account and all of its resources') }}
-
-
-
-
- {{ __('Delete account') }}
-
-
-
-
-
-
-
diff --git a/resources/views/livewire/settings/password.blade.php b/resources/views/livewire/settings/password.blade.php
deleted file mode 100644
index 10868a86..00000000
--- a/resources/views/livewire/settings/password.blade.php
+++ /dev/null
@@ -1,41 +0,0 @@
-
- @include('partials.settings-heading')
-
- {{ __('Password Settings') }}
-
-
-
-
-
diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php
deleted file mode 100644
index 4de634b8..00000000
--- a/resources/views/livewire/settings/profile.blade.php
+++ /dev/null
@@ -1,47 +0,0 @@
-
- @include('partials.settings-heading')
-
- {{ __('Profile Settings') }}
-
-
-
-
- @if ($this->showDeleteUser)
-
- @endif
-
-
diff --git a/resources/views/livewire/settings/two-factor.blade.php b/resources/views/livewire/settings/two-factor.blade.php
deleted file mode 100644
index fc01f3e7..00000000
--- a/resources/views/livewire/settings/two-factor.blade.php
+++ /dev/null
@@ -1,210 +0,0 @@
-
- @include('partials.settings-heading')
-
- {{ __('Two-Factor Authentication Settings') }}
-
-
-
- @if ($twoFactorEnabled)
-
-
- {{ __('Enabled') }}
-
-
-
- {{ __('With two-factor authentication enabled, you will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
-
-
-
-
-
-
- {{ __('Disable 2FA') }}
-
-
-
- @else
-
-
- {{ __('Disabled') }}
-
-
-
- {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
-
-
-
- {{ __('Enable 2FA') }}
-
-
- @endif
-
-
-
-
-
-
-
-
-
- @for ($i = 1; $i <= 5; $i++)
-
- @endfor
-
-
-
- @for ($i = 1; $i <= 5; $i++)
-
- @endfor
-
-
-
-
-
-
-
- {{ $this->modalConfig['title'] }}
- {{ $this->modalConfig['description'] }}
-
-
-
- @if ($showVerificationStep)
-
-
-
-
-
-
-
- {{ __('Back') }}
-
-
-
- {{ __('Confirm') }}
-
-
-
- @else
- @error('setupData')
-
- @enderror
-
-
-
- @empty($qrCodeSvg)
-
-
-
- @else
-
-
- {!! $qrCodeSvg !!}
-
-
- @endempty
-
-
-
-
-
- {{ $this->modalConfig['buttonText'] }}
-
-
-
-
-
-
-
- {{ __('or, enter the code manually') }}
-
-
-
-
-
- @empty($manualSetupKey)
-
-
-
- @else
-
-
-
-
-
-
- @endempty
-
-
-
- @endif
-
-
-
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php
deleted file mode 100644
index 0c4232a8..00000000
--- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php
+++ /dev/null
@@ -1,89 +0,0 @@
-
-
-
-
- {{ __('2FA Recovery Codes') }}
-
-
- {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
-
-
-
-
-
-
- {{ __('View Recovery Codes') }}
-
-
-
- {{ __('Hide Recovery Codes') }}
-
-
- @if (filled($recoveryCodes))
-
- {{ __('Regenerate Codes') }}
-
- @endif
-
-
-
-
- @error('recoveryCodes')
-
- @enderror
-
- @if (filled($recoveryCodes))
-
- @foreach($recoveryCodes as $code)
-
- {{ $code }}
-
- @endforeach
-
-
- {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate Codes above.') }}
-
- @endif
-
-
-
-
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php
deleted file mode 100644
index dce80588..00000000
--- a/resources/views/partials/head.blade.php
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-{{ $title ?? config('app.name') }}
-
-
-
-
-
-
-
-
-@vite(['resources/css/app.css', 'resources/js/app.js'])
-@fluxAppearance
diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php
deleted file mode 100644
index 925ace9a..00000000
--- a/resources/views/partials/settings-heading.blade.php
+++ /dev/null
@@ -1,5 +0,0 @@
-
- {{ __('Settings') }}
- {{ __('Manage your profile and account settings') }}
-
-
diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php
deleted file mode 100644
index a808a399..00000000
--- a/resources/views/welcome.blade.php
+++ /dev/null
@@ -1,278 +0,0 @@
-
-
-
-
-
-
- Laravel
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @if (Route::has('login'))
-
- @auth
-
- Dashboard
-
- @else
-
- Log in
-
-
- @if (Route::has('register'))
-
- Register
-
- @endif
- @endauth
-
- @endif
-
-
-
-
-
Let's get started
-
Laravel has an incredibly rich ecosystem. We suggest starting with the following.
-
-
-
-
- {{-- Laravel Logo --}}
-
-
-
-
-
-
-
-
-
-
- {{-- Light Mode 12 SVG --}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{-- Dark Mode 12 SVG --}}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- @if (Route::has('login'))
-
- @endif
-
-
diff --git a/routes/settings.php b/routes/settings.php
deleted file mode 100644
index 2019a287..00000000
--- a/routes/settings.php
+++ /dev/null
@@ -1,30 +0,0 @@
-group(function () {
- Route::redirect('settings', 'settings/profile');
-
- Route::livewire('settings/profile', Profile::class)->name('profile.edit');
-});
-
-Route::middleware(['auth', 'verified'])->group(function () {
- Route::livewire('settings/password', Password::class)->name('user-password.edit');
- Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit');
-
- Route::livewire('settings/two-factor', TwoFactor::class)
- ->middleware(
- when(
- Features::canManageTwoFactorAuthentication()
- && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
- ['password.confirm'],
- [],
- ),
- )
- ->name('two-factor.show');
-});
diff --git a/specs/progress.md b/specs/progress.md
new file mode 100644
index 00000000..8ec24534
--- /dev/null
+++ b/specs/progress.md
@@ -0,0 +1,108 @@
+# Shop Implementation Progress
+
+> Living document tracking the implementation of the shop system per specs/*.md.
+> Updated after every iteration.
+
+## Status Legend
+- `[ ]` pending
+- `[~]` in progress
+- `[x]` done (implemented + tested)
+
+---
+
+## Iteration Log
+
+| # | Date | Phase | Summary | Commit |
+|---|------|-------|---------|--------|
+| 0 | 2026-07-19 | Setup | Project scaffolding: sanctum + pest-plugin-browser deps, .env.testing, Herd site links (acme-fashion.test, acme-electronics.test), starter-kit cleanup | - |
+
+---
+
+## Phase Checklist
+
+### Phase 1 — Foundation
+- [ ] Config: database pragmas, session, cache, queue, auth (customer guard), logging (json + audit), cors
+- [ ] All 46-table migrations (spec 01, dependency order)
+- [ ] Core models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User
+- [ ] Enums (all, spec 05 §21)
+- [ ] ResolveStore / CheckStoreRole / CustomerAuthenticate middleware
+- [ ] BelongsToStore trait + StoreScope
+- [ ] CustomerUserProvider
+- [ ] Policies + ChecksStoreRole trait + Gates
+- [ ] Rate limiters
+- [ ] Tests: Tenancy (TenantResolutionTest, StoreIsolationTest)
+
+### Phase 2 — Catalog
+- [ ] Models: Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia
+- [ ] ProductService, VariantMatrixService, InventoryService, HandleGenerator
+- [ ] ProcessMediaUpload job
+- [ ] SanitizeHtml action
+- [ ] Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest, HandleGeneratorTest
+
+### Phase 3 — Themes / Pages / Navigation / Storefront layout
+- [ ] Models: Theme, ThemeFile, ThemeSettings, Page, NavigationMenu, NavigationItem
+- [ ] NavigationService, ThemeSettings service
+- [ ] Storefront layout + components (product-card, price, badge, etc.)
+- [ ] Storefront Livewire: Home, Collections Index/Show, Products Show, Pages Show
+
+### Phase 4 — Cart / Checkout / Discounts / Shipping / Taxes
+- [ ] Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
+- [ ] CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService
+- [ ] Value objects: PricingResult, TaxLine, Address, etc.
+- [ ] Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts
+- [ ] Storefront cart/checkout Livewire UI + REST API endpoints
+- [ ] Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest
+
+### Phase 5 — Payments / Orders / Fulfillment
+- [ ] Models: Customer, CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine
+- [ ] MockPaymentProvider, PaymentService, OrderService, RefundService, FulfillmentService, CustomerService
+- [ ] Events: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded, etc.
+- [ ] Jobs: CancelUnpaidBankTransferOrders
+- [ ] Tests: OrderCreationTest, RefundTest, FulfillmentTest, MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest
+
+### Phase 6 — Customer Accounts + Auth UI
+- [ ] Admin auth: Login, Logout, ForgotPassword, ResetPassword (Livewire)
+- [ ] Customer auth: Login, Register, ForgotPassword, ResetPassword (Livewire)
+- [ ] Account pages: Dashboard, Orders Index/Show, Addresses Index
+- [ ] Tests: AdminAuthTest, CustomerAuthTest, SanctumTokenTest, CustomerAccountTest, AddressManagementTest
+
+### Phase 7 — Admin Panel
+- [ ] Admin layout (sidebar, topbar, breadcrumbs, toasts)
+- [ ] Dashboard (KPIs, chart, recent orders)
+- [ ] Products (index, form with variants builder, media upload)
+- [ ] Orders (index, show with fulfillment/refund modals, confirm payment)
+- [ ] Collections, Customers, Discounts, Settings (general/domains/shipping/taxes), Themes, Pages, Navigation, Inventory
+- [ ] Tests: DashboardTest, ProductManagementTest, OrderManagementTest, DiscountManagementTest, SettingsTest
+
+### Phase 8 — Search
+- [ ] FTS5 migration (products_fts), SearchService, ProductObserver
+- [ ] SearchSettings model, admin Search Settings page
+- [ ] Storefront Search Modal + Index
+- [ ] Tests: SearchTest, AutocompleteTest
+
+### Phase 9 — Analytics
+- [ ] AnalyticsEvent, AnalyticsDaily models, AnalyticsService, AggregateAnalytics job
+- [ ] Storefront event tracking + API endpoint
+- [ ] Admin Analytics page
+- [ ] Tests: EventIngestionTest, AggregationTest
+
+### Phase 10 — Apps / Webhooks / Developers / Admin REST API
+- [ ] Models: App, AppInstallation, OauthClient, OauthToken, WebhookSubscription, WebhookDelivery
+- [ ] WebhookService, DeliverWebhook job, DispatchWebhooks listener
+- [ ] Admin Apps + Developers pages (Sanctum token management)
+- [ ] Admin REST API (/api/admin/v1): products, collections, orders, customers, discounts, platform
+- [ ] Tests: WebhookDeliveryTest, WebhookSignatureTest, SanctumTokenTest, AdminProductApiTest, AdminOrderApiTest, StorefrontCartApiTest, StorefrontCheckoutApiTest
+
+### Phase 11 — Seeders
+- [ ] Exact demo data per spec 07 (2 stores, 5 users, 20+ products, collections, discounts, shipping, tax, pages, navigation, orders, customers)
+
+### Phase 12 — Polish
+- [ ] Error pages 404/503, dark mode, accessibility, structured logging
+- [ ] Pint clean, full test suite green, fresh migrate+seed verified
+
+### Browser E2E
+- [ ] Pest browser tests per spec 08 (18 files, 143 tests)
+- [ ] Playwright MCP verification of all acceptance criteria
+
+### Final
+- [ ] Review meeting: showcase all customer + admin features
diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php
deleted file mode 100644
index fff11fd7..00000000
--- a/tests/Feature/Auth/AuthenticationTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-get(route('login'));
-
- $response->assertOk();
-});
-
-test('users can authenticate using the login screen', function () {
- $user = User::factory()->create();
-
- $response = $this->post(route('login.store'), [
- 'email' => $user->email,
- 'password' => 'password',
- ]);
-
- $response
- ->assertSessionHasNoErrors()
- ->assertRedirect(route('dashboard', absolute: false));
-
- $this->assertAuthenticated();
-});
-
-test('users can not authenticate with invalid password', function () {
- $user = User::factory()->create();
-
- $response = $this->post(route('login.store'), [
- 'email' => $user->email,
- 'password' => 'wrong-password',
- ]);
-
- $response->assertSessionHasErrorsIn('email');
-
- $this->assertGuest();
-});
-
-test('users with two factor enabled are redirected to two factor challenge', function () {
- if (! Features::canManageTwoFactorAuthentication()) {
- $this->markTestSkipped('Two-factor authentication is not enabled.');
- }
- Features::twoFactorAuthentication([
- 'confirm' => true,
- 'confirmPassword' => true,
- ]);
-
- $user = User::factory()->withTwoFactor()->create();
-
- $response = $this->post(route('login.store'), [
- 'email' => $user->email,
- 'password' => 'password',
- ]);
-
- $response->assertRedirect(route('two-factor.login'));
- $this->assertGuest();
-});
-
-test('users can logout', function () {
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)->post(route('logout'));
-
- $response->assertRedirect(route('home'));
- $this->assertGuest();
-});
\ No newline at end of file
diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php
deleted file mode 100644
index 66f58e36..00000000
--- a/tests/Feature/Auth/EmailVerificationTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-unverified()->create();
-
- $response = $this->actingAs($user)->get(route('verification.notice'));
-
- $response->assertOk();
-});
-
-test('email can be verified', function () {
- $user = User::factory()->unverified()->create();
-
- Event::fake();
-
- $verificationUrl = URL::temporarySignedRoute(
- 'verification.verify',
- now()->addMinutes(60),
- ['id' => $user->id, 'hash' => sha1($user->email)]
- );
-
- $response = $this->actingAs($user)->get($verificationUrl);
-
- Event::assertDispatched(Verified::class);
-
- expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
- $response->assertRedirect(route('dashboard', absolute: false).'?verified=1');
-});
-
-test('email is not verified with invalid hash', function () {
- $user = User::factory()->unverified()->create();
-
- $verificationUrl = URL::temporarySignedRoute(
- 'verification.verify',
- now()->addMinutes(60),
- ['id' => $user->id, 'hash' => sha1('wrong-email')]
- );
-
- $this->actingAs($user)->get($verificationUrl);
-
- expect($user->fresh()->hasVerifiedEmail())->toBeFalse();
-});
-
-test('already verified user visiting verification link is redirected without firing event again', function () {
- $user = User::factory()->create([
- 'email_verified_at' => now(),
- ]);
-
- Event::fake();
-
- $verificationUrl = URL::temporarySignedRoute(
- 'verification.verify',
- now()->addMinutes(60),
- ['id' => $user->id, 'hash' => sha1($user->email)]
- );
-
- $this->actingAs($user)->get($verificationUrl)
- ->assertRedirect(route('dashboard', absolute: false).'?verified=1');
-
- expect($user->fresh()->hasVerifiedEmail())->toBeTrue();
- Event::assertNotDispatched(Verified::class);
-});
\ No newline at end of file
diff --git a/tests/Feature/Auth/PasswordConfirmationTest.php b/tests/Feature/Auth/PasswordConfirmationTest.php
deleted file mode 100644
index f42a259e..00000000
--- a/tests/Feature/Auth/PasswordConfirmationTest.php
+++ /dev/null
@@ -1,13 +0,0 @@
-create();
-
- $response = $this->actingAs($user)->get(route('password.confirm'));
-
- $response->assertOk();
-});
\ No newline at end of file
diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php
deleted file mode 100644
index bea78251..00000000
--- a/tests/Feature/Auth/PasswordResetTest.php
+++ /dev/null
@@ -1,61 +0,0 @@
-get(route('password.request'));
-
- $response->assertOk();
-});
-
-test('reset password link can be requested', function () {
- Notification::fake();
-
- $user = User::factory()->create();
-
- $this->post(route('password.request'), ['email' => $user->email]);
-
- Notification::assertSentTo($user, ResetPassword::class);
-});
-
-test('reset password screen can be rendered', function () {
- Notification::fake();
-
- $user = User::factory()->create();
-
- $this->post(route('password.request'), ['email' => $user->email]);
-
- Notification::assertSentTo($user, ResetPassword::class, function ($notification) {
- $response = $this->get(route('password.reset', $notification->token));
- $response->assertOk();
-
- return true;
- });
-});
-
-test('password can be reset with valid token', function () {
- Notification::fake();
-
- $user = User::factory()->create();
-
- $this->post(route('password.request'), ['email' => $user->email]);
-
- Notification::assertSentTo($user, ResetPassword::class, function ($notification) use ($user) {
- $response = $this->post(route('password.update'), [
- 'token' => $notification->token,
- 'email' => $user->email,
- 'password' => 'password',
- 'password_confirmation' => 'password',
- ]);
-
- $response
- ->assertSessionHasNoErrors()
- ->assertRedirect(route('login', absolute: false));
-
- return true;
- });
-});
\ No newline at end of file
diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php
deleted file mode 100644
index c22ea5e1..00000000
--- a/tests/Feature/Auth/RegistrationTest.php
+++ /dev/null
@@ -1,23 +0,0 @@
-get(route('register'));
-
- $response->assertOk();
-});
-
-test('new users can register', function () {
- $response = $this->post(route('register.store'), [
- 'name' => 'John Doe',
- 'email' => 'test@example.com',
- 'password' => 'password',
- 'password_confirmation' => 'password',
- ]);
-
- $response->assertSessionHasNoErrors()
- ->assertRedirect(route('dashboard', absolute: false));
-
- $this->assertAuthenticated();
-});
\ No newline at end of file
diff --git a/tests/Feature/Auth/TwoFactorChallengeTest.php b/tests/Feature/Auth/TwoFactorChallengeTest.php
deleted file mode 100644
index cda794f2..00000000
--- a/tests/Feature/Auth/TwoFactorChallengeTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
-markTestSkipped('Two-factor authentication is not enabled.');
- }
-
- $response = $this->get(route('two-factor.login'));
-
- $response->assertRedirect(route('login'));
-});
-
-test('two factor challenge can be rendered', function () {
- if (! Features::canManageTwoFactorAuthentication()) {
- $this->markTestSkipped('Two-factor authentication is not enabled.');
- }
-
- Features::twoFactorAuthentication([
- 'confirm' => true,
- 'confirmPassword' => true,
- ]);
-
- $user = User::factory()->withTwoFactor()->create();
-
- $this->post(route('login.store'), [
- 'email' => $user->email,
- 'password' => 'password',
- ])->assertRedirect(route('two-factor.login'));
-});
\ No newline at end of file
diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php
deleted file mode 100644
index fcd0258d..00000000
--- a/tests/Feature/DashboardTest.php
+++ /dev/null
@@ -1,18 +0,0 @@
-get(route('dashboard'));
- $response->assertRedirect(route('login'));
-});
-
-test('authenticated users can visit the dashboard', function () {
- $user = User::factory()->create();
- $this->actingAs($user);
-
- $response = $this->get(route('dashboard'));
- $response->assertOk();
-});
\ No newline at end of file
diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php
deleted file mode 100644
index 8b5843f4..00000000
--- a/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,7 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
-});
diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php
deleted file mode 100644
index a6379b2b..00000000
--- a/tests/Feature/Settings/PasswordUpdateTest.php
+++ /dev/null
@@ -1,42 +0,0 @@
-create([
- 'password' => Hash::make('password'),
- ]);
-
- $this->actingAs($user);
-
- $response = Livewire::test(Password::class)
- ->set('current_password', 'password')
- ->set('password', 'new-password')
- ->set('password_confirmation', 'new-password')
- ->call('updatePassword');
-
- $response->assertHasNoErrors();
-
- expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
-});
-
-test('correct password must be provided to update password', function () {
- $user = User::factory()->create([
- 'password' => Hash::make('password'),
- ]);
-
- $this->actingAs($user);
-
- $response = Livewire::test(Password::class)
- ->set('current_password', 'wrong-password')
- ->set('password', 'new-password')
- ->set('password_confirmation', 'new-password')
- ->call('updatePassword');
-
- $response->assertHasErrors(['current_password']);
-});
\ No newline at end of file
diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php
deleted file mode 100644
index 276e9fef..00000000
--- a/tests/Feature/Settings/ProfileUpdateTest.php
+++ /dev/null
@@ -1,78 +0,0 @@
-actingAs($user = User::factory()->create());
-
- $this->get('/settings/profile')->assertOk();
-});
-
-test('profile information can be updated', function () {
- $user = User::factory()->create();
-
- $this->actingAs($user);
-
- $response = Livewire::test(Profile::class)
- ->set('name', 'Test User')
- ->set('email', 'test@example.com')
- ->call('updateProfileInformation');
-
- $response->assertHasNoErrors();
-
- $user->refresh();
-
- expect($user->name)->toEqual('Test User');
- expect($user->email)->toEqual('test@example.com');
- expect($user->email_verified_at)->toBeNull();
-});
-
-test('email verification status is unchanged when email address is unchanged', function () {
- $user = User::factory()->create();
-
- $this->actingAs($user);
-
- $response = Livewire::test(Profile::class)
- ->set('name', 'Test User')
- ->set('email', $user->email)
- ->call('updateProfileInformation');
-
- $response->assertHasNoErrors();
-
- expect($user->refresh()->email_verified_at)->not->toBeNull();
-});
-
-test('user can delete their account', function () {
- $user = User::factory()->create();
-
- $this->actingAs($user);
-
- $response = Livewire::test('settings.delete-user-form')
- ->set('password', 'password')
- ->call('deleteUser');
-
- $response
- ->assertHasNoErrors()
- ->assertRedirect('/');
-
- expect($user->fresh())->toBeNull();
- expect(auth()->check())->toBeFalse();
-});
-
-test('correct password must be provided to delete account', function () {
- $user = User::factory()->create();
-
- $this->actingAs($user);
-
- $response = Livewire::test('settings.delete-user-form')
- ->set('password', 'wrong-password')
- ->call('deleteUser');
-
- $response->assertHasErrors(['password']);
-
- expect($user->fresh())->not->toBeNull();
-});
\ No newline at end of file
diff --git a/tests/Feature/Settings/TwoFactorAuthenticationTest.php b/tests/Feature/Settings/TwoFactorAuthenticationTest.php
deleted file mode 100644
index e2d530fb..00000000
--- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php
+++ /dev/null
@@ -1,72 +0,0 @@
-markTestSkipped('Two-factor authentication is not enabled.');
- }
-
- Features::twoFactorAuthentication([
- 'confirm' => true,
- 'confirmPassword' => true,
- ]);
-});
-
-test('two factor settings page can be rendered', function () {
- $user = User::factory()->create();
-
- $this->actingAs($user)
- ->withSession(['auth.password_confirmed_at' => time()])
- ->get(route('two-factor.show'))
- ->assertOk()
- ->assertSee('Two Factor Authentication')
- ->assertSee('Disabled');
-});
-
-test('two factor settings page requires password confirmation when enabled', function () {
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)
- ->get(route('two-factor.show'));
-
- $response->assertRedirect(route('password.confirm'));
-});
-
-test('two factor settings page returns forbidden response when two factor is disabled', function () {
- config(['fortify.features' => []]);
-
- $user = User::factory()->create();
-
- $response = $this->actingAs($user)
- ->withSession(['auth.password_confirmed_at' => time()])
- ->get(route('two-factor.show'));
-
- $response->assertForbidden();
-});
-
-test('two factor authentication disabled when confirmation abandoned between requests', function () {
- $user = User::factory()->create();
-
- $user->forceFill([
- 'two_factor_secret' => encrypt('test-secret'),
- 'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
- 'two_factor_confirmed_at' => null,
- ])->save();
-
- $this->actingAs($user);
-
- $component = Livewire::test('settings.two-factor');
-
- $component->assertSet('twoFactorEnabled', false);
-
- $this->assertDatabaseHas('users', [
- 'id' => $user->id,
- 'two_factor_secret' => null,
- 'two_factor_recovery_codes' => null,
- ]);
-});
\ No newline at end of file
diff --git a/tests/Unit/ExampleTest.php b/tests/Unit/ExampleTest.php
deleted file mode 100644
index 44a4f337..00000000
--- a/tests/Unit/ExampleTest.php
+++ /dev/null
@@ -1,5 +0,0 @@
-toBeTrue();
-});
From 342ff904768d6545aa33a5247c16b75f77aeed97 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 11:08:18 +0200
Subject: [PATCH 03/29] =?UTF-8?q?Phase=201:=20Foundation=20=E2=80=94=2046-?=
=?UTF-8?q?table=20schema,=20core=20models,=2028=20enums,=20tenant=20middl?=
=?UTF-8?q?eware,=20RBAC=20policies/gates,=20auth=20guards?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- All migrations per spec 01 in FK dependency order (SQLite WAL, FK on)
- BelongsToStore trait + StoreScope (no-op when current_store unbound)
- ResolveStore/CheckStoreRole/CustomerAuthenticate middleware
- Customer guard + CustomerUserProvider (store-scoped credentials)
- 11 policies + ChecksStoreRole trait + 9 gates + 7 rate limiters
- User model: password_hash auth, Sanctum tokens, roleForStore()
- Tenancy tests: 15 passed
---
app/Auth/CustomerUserProvider.php | 27 ++
app/Enums/CartStatus.php | 10 +
app/Enums/CheckoutStatus.php | 13 +
app/Enums/CollectionStatus.php | 10 +
app/Enums/CollectionType.php | 9 +
app/Enums/DiscountStatus.php | 11 +
app/Enums/DiscountType.php | 9 +
app/Enums/DiscountValueType.php | 10 +
app/Enums/FinancialStatus.php | 13 +
app/Enums/FulfillmentOrderStatus.php | 10 +
app/Enums/FulfillmentShipmentStatus.php | 10 +
app/Enums/InventoryPolicy.php | 9 +
app/Enums/MediaStatus.php | 10 +
app/Enums/MediaType.php | 9 +
app/Enums/NavigationItemType.php | 11 +
app/Enums/OrderStatus.php | 12 +
app/Enums/PageStatus.php | 10 +
app/Enums/PaymentMethod.php | 10 +
app/Enums/PaymentStatus.php | 11 +
app/Enums/ProductStatus.php | 10 +
app/Enums/RefundStatus.php | 10 +
app/Enums/ShippingRateType.php | 11 +
app/Enums/StoreDomainType.php | 10 +
app/Enums/StoreStatus.php | 9 +
app/Enums/StoreUserRole.php | 11 +
app/Enums/TaxMode.php | 9 +
app/Enums/ThemeStatus.php | 9 +
app/Enums/VariantStatus.php | 9 +
app/Enums/WebhookSubscriptionStatus.php | 10 +
app/Http/Middleware/CheckStoreRole.php | 42 +++
app/Http/Middleware/CustomerAuthenticate.php | 27 ++
app/Http/Middleware/ResolveStore.php | 86 ++++++
app/Models/Concerns/BelongsToStore.php | 24 ++
app/Models/Customer.php | 57 ++++
app/Models/Organization.php | 33 ++
app/Models/Scopes/StoreScope.php | 26 ++
app/Models/Store.php | 86 ++++++
app/Models/StoreDomain.php | 56 ++++
app/Models/StoreSettings.php | 65 ++++
app/Models/StoreUser.php | 45 +++
app/Models/User.php | 56 +++-
app/Policies/CollectionPolicy.php | 41 +++
app/Policies/CustomerPolicy.php | 29 ++
app/Policies/DiscountPolicy.php | 41 +++
app/Policies/FulfillmentPolicy.php | 32 ++
app/Policies/NavigationMenuPolicy.php | 25 ++
app/Policies/OrderPolicy.php | 44 +++
app/Policies/PagePolicy.php | 41 +++
app/Policies/ProductPolicy.php | 51 +++
app/Policies/RefundPolicy.php | 21 ++
app/Policies/StorePolicy.php | 28 ++
app/Policies/ThemePolicy.php | 46 +++
app/Providers/AppServiceProvider.php | 80 +++++
app/Traits/ChecksStoreRole.php | 68 ++++
bootstrap/app.php | 14 +-
composer.json | 1 -
composer.lock | 291 +-----------------
config/auth.php | 20 +-
config/cors.php | 31 ++
config/database.php | 6 +-
config/logging.php | 17 +
database/factories/CustomerFactory.php | 44 +++
database/factories/OrganizationFactory.php | 24 ++
database/factories/StoreDomainFactory.php | 29 ++
database/factories/StoreFactory.php | 31 ++
database/factories/StoreSettingsFactory.php | 25 ++
database/factories/StoreUserFactory.php | 28 ++
database/factories/UserFactory.php | 5 +-
.../0001_01_01_000000_create_users_table.php | 16 +-
...1_01_000001_create_organizations_table.php | 31 ++
.../2026_01_01_000002_create_apps_table.php | 31 ++
.../2026_01_01_000003_create_stores_table.php | 38 +++
...1_01_000004_create_store_domains_table.php | 36 +++
..._01_01_000005_create_store_users_table.php | 33 ++
..._01_000006_create_store_settings_table.php | 28 ++
...26_01_01_000007_create_customers_table.php | 35 +++
.../2026_01_01_000008_create_themes_table.php | 35 +++
.../2026_01_01_000009_create_pages_table.php | 37 +++
...1_000010_create_navigation_menus_table.php | 33 ++
...01_000011_create_search_settings_table.php | 29 ++
..._01_000012_create_shipping_zones_table.php | 32 ++
...01_01_000013_create_tax_settings_table.php | 30 ++
...26_01_01_000014_create_discounts_table.php | 43 +++
...026_01_01_000015_create_products_table.php | 43 +++
..._01_01_000016_create_collections_table.php | 37 +++
..._000017_create_app_installations_table.php | 35 +++
...1_01_000018_create_oauth_clients_table.php | 33 ++
...01_000019_create_product_options_table.php | 32 ++
...1_000020_create_product_variants_table.php | 44 +++
...1_01_000021_create_product_media_table.php | 41 +++
...00022_create_collection_products_table.php | 32 ++
...000023_create_customer_addresses_table.php | 33 ++
.../2026_01_01_000024_create_carts_table.php | 36 +++
...1_000025_create_navigation_items_table.php | 35 +++
..._01_01_000026_create_theme_files_table.php | 34 ++
..._01_000027_create_theme_settings_table.php | 28 ++
..._01_000028_create_search_queries_table.php | 35 +++
..._01_000029_create_shipping_rates_table.php | 34 ++
...01_01_000030_create_oauth_tokens_table.php | 34 ++
...031_create_webhook_subscriptions_table.php | 36 +++
...032_create_product_option_values_table.php | 32 ++
...01_000033_create_inventory_items_table.php | 34 ++
...6_01_01_000034_create_cart_lines_table.php | 36 +++
...26_01_01_000035_create_checkouts_table.php | 46 +++
.../2026_01_01_000036_create_orders_table.php | 52 ++++
...1_000037_create_analytics_events_table.php | 41 +++
...01_000038_create_analytics_daily_table.php | 37 +++
...000039_create_webhook_deliveries_table.php | 38 +++
...040_create_variant_option_values_table.php | 30 ++
..._01_01_000041_create_order_lines_table.php | 40 +++
...026_01_01_000042_create_payments_table.php | 40 +++
...01_01_000043_create_fulfillments_table.php | 37 +++
...2026_01_01_000044_create_refunds_table.php | 37 +++
..._000045_create_fulfillment_lines_table.php | 32 ++
...e_customer_password_reset_tokens_table.php | 31 ++
routes/admin.php | 17 +
routes/api.php | 17 +
routes/web.php | 13 +-
specs/progress.md | 3 +-
tests/Feature/Tenancy/StoreIsolationTest.php | 45 +++
.../Feature/Tenancy/TenantResolutionTest.php | 65 ++++
tests/Pest.php | 2 +-
tests/TestCase.php | 47 ++-
tests/Unit/EnumsTest.php | 32 ++
124 files changed, 3596 insertions(+), 325 deletions(-)
create mode 100644 app/Auth/CustomerUserProvider.php
create mode 100644 app/Enums/CartStatus.php
create mode 100644 app/Enums/CheckoutStatus.php
create mode 100644 app/Enums/CollectionStatus.php
create mode 100644 app/Enums/CollectionType.php
create mode 100644 app/Enums/DiscountStatus.php
create mode 100644 app/Enums/DiscountType.php
create mode 100644 app/Enums/DiscountValueType.php
create mode 100644 app/Enums/FinancialStatus.php
create mode 100644 app/Enums/FulfillmentOrderStatus.php
create mode 100644 app/Enums/FulfillmentShipmentStatus.php
create mode 100644 app/Enums/InventoryPolicy.php
create mode 100644 app/Enums/MediaStatus.php
create mode 100644 app/Enums/MediaType.php
create mode 100644 app/Enums/NavigationItemType.php
create mode 100644 app/Enums/OrderStatus.php
create mode 100644 app/Enums/PageStatus.php
create mode 100644 app/Enums/PaymentMethod.php
create mode 100644 app/Enums/PaymentStatus.php
create mode 100644 app/Enums/ProductStatus.php
create mode 100644 app/Enums/RefundStatus.php
create mode 100644 app/Enums/ShippingRateType.php
create mode 100644 app/Enums/StoreDomainType.php
create mode 100644 app/Enums/StoreStatus.php
create mode 100644 app/Enums/StoreUserRole.php
create mode 100644 app/Enums/TaxMode.php
create mode 100644 app/Enums/ThemeStatus.php
create mode 100644 app/Enums/VariantStatus.php
create mode 100644 app/Enums/WebhookSubscriptionStatus.php
create mode 100644 app/Http/Middleware/CheckStoreRole.php
create mode 100644 app/Http/Middleware/CustomerAuthenticate.php
create mode 100644 app/Http/Middleware/ResolveStore.php
create mode 100644 app/Models/Concerns/BelongsToStore.php
create mode 100644 app/Models/Customer.php
create mode 100644 app/Models/Organization.php
create mode 100644 app/Models/Scopes/StoreScope.php
create mode 100644 app/Models/Store.php
create mode 100644 app/Models/StoreDomain.php
create mode 100644 app/Models/StoreSettings.php
create mode 100644 app/Models/StoreUser.php
create mode 100644 app/Policies/CollectionPolicy.php
create mode 100644 app/Policies/CustomerPolicy.php
create mode 100644 app/Policies/DiscountPolicy.php
create mode 100644 app/Policies/FulfillmentPolicy.php
create mode 100644 app/Policies/NavigationMenuPolicy.php
create mode 100644 app/Policies/OrderPolicy.php
create mode 100644 app/Policies/PagePolicy.php
create mode 100644 app/Policies/ProductPolicy.php
create mode 100644 app/Policies/RefundPolicy.php
create mode 100644 app/Policies/StorePolicy.php
create mode 100644 app/Policies/ThemePolicy.php
create mode 100644 app/Traits/ChecksStoreRole.php
create mode 100644 config/cors.php
create mode 100644 database/factories/CustomerFactory.php
create mode 100644 database/factories/OrganizationFactory.php
create mode 100644 database/factories/StoreDomainFactory.php
create mode 100644 database/factories/StoreFactory.php
create mode 100644 database/factories/StoreSettingsFactory.php
create mode 100644 database/factories/StoreUserFactory.php
create mode 100644 database/migrations/2026_01_01_000001_create_organizations_table.php
create mode 100644 database/migrations/2026_01_01_000002_create_apps_table.php
create mode 100644 database/migrations/2026_01_01_000003_create_stores_table.php
create mode 100644 database/migrations/2026_01_01_000004_create_store_domains_table.php
create mode 100644 database/migrations/2026_01_01_000005_create_store_users_table.php
create mode 100644 database/migrations/2026_01_01_000006_create_store_settings_table.php
create mode 100644 database/migrations/2026_01_01_000007_create_customers_table.php
create mode 100644 database/migrations/2026_01_01_000008_create_themes_table.php
create mode 100644 database/migrations/2026_01_01_000009_create_pages_table.php
create mode 100644 database/migrations/2026_01_01_000010_create_navigation_menus_table.php
create mode 100644 database/migrations/2026_01_01_000011_create_search_settings_table.php
create mode 100644 database/migrations/2026_01_01_000012_create_shipping_zones_table.php
create mode 100644 database/migrations/2026_01_01_000013_create_tax_settings_table.php
create mode 100644 database/migrations/2026_01_01_000014_create_discounts_table.php
create mode 100644 database/migrations/2026_01_01_000015_create_products_table.php
create mode 100644 database/migrations/2026_01_01_000016_create_collections_table.php
create mode 100644 database/migrations/2026_01_01_000017_create_app_installations_table.php
create mode 100644 database/migrations/2026_01_01_000018_create_oauth_clients_table.php
create mode 100644 database/migrations/2026_01_01_000019_create_product_options_table.php
create mode 100644 database/migrations/2026_01_01_000020_create_product_variants_table.php
create mode 100644 database/migrations/2026_01_01_000021_create_product_media_table.php
create mode 100644 database/migrations/2026_01_01_000022_create_collection_products_table.php
create mode 100644 database/migrations/2026_01_01_000023_create_customer_addresses_table.php
create mode 100644 database/migrations/2026_01_01_000024_create_carts_table.php
create mode 100644 database/migrations/2026_01_01_000025_create_navigation_items_table.php
create mode 100644 database/migrations/2026_01_01_000026_create_theme_files_table.php
create mode 100644 database/migrations/2026_01_01_000027_create_theme_settings_table.php
create mode 100644 database/migrations/2026_01_01_000028_create_search_queries_table.php
create mode 100644 database/migrations/2026_01_01_000029_create_shipping_rates_table.php
create mode 100644 database/migrations/2026_01_01_000030_create_oauth_tokens_table.php
create mode 100644 database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php
create mode 100644 database/migrations/2026_01_01_000032_create_product_option_values_table.php
create mode 100644 database/migrations/2026_01_01_000033_create_inventory_items_table.php
create mode 100644 database/migrations/2026_01_01_000034_create_cart_lines_table.php
create mode 100644 database/migrations/2026_01_01_000035_create_checkouts_table.php
create mode 100644 database/migrations/2026_01_01_000036_create_orders_table.php
create mode 100644 database/migrations/2026_01_01_000037_create_analytics_events_table.php
create mode 100644 database/migrations/2026_01_01_000038_create_analytics_daily_table.php
create mode 100644 database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php
create mode 100644 database/migrations/2026_01_01_000040_create_variant_option_values_table.php
create mode 100644 database/migrations/2026_01_01_000041_create_order_lines_table.php
create mode 100644 database/migrations/2026_01_01_000042_create_payments_table.php
create mode 100644 database/migrations/2026_01_01_000043_create_fulfillments_table.php
create mode 100644 database/migrations/2026_01_01_000044_create_refunds_table.php
create mode 100644 database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php
create mode 100644 database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php
create mode 100644 routes/admin.php
create mode 100644 routes/api.php
create mode 100644 tests/Feature/Tenancy/StoreIsolationTest.php
create mode 100644 tests/Feature/Tenancy/TenantResolutionTest.php
create mode 100644 tests/Unit/EnumsTest.php
diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php
new file mode 100644
index 00000000..873530b7
--- /dev/null
+++ b/app/Auth/CustomerUserProvider.php
@@ -0,0 +1,27 @@
+ $credentials
+ */
+ public function retrieveByCredentials(array $credentials): ?Authenticatable
+ {
+ if (app()->bound('current_store')) {
+ $credentials['store_id'] = app('current_store')->getKey();
+ }
+
+ return parent::retrieveByCredentials($credentials);
+ }
+}
diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php
new file mode 100644
index 00000000..56a92071
--- /dev/null
+++ b/app/Enums/CartStatus.php
@@ -0,0 +1,10 @@
+bound('current_store'), 403, 'You do not have access to this store.');
+
+ $store = app('current_store');
+ $user = $request->user();
+
+ $storeUser = $user === null ? null : StoreUser::query()
+ ->where('store_id', $store->getKey())
+ ->where('user_id', $user->getKey())
+ ->first();
+
+ abort_if($storeUser === null, 403, 'You do not have access to this store.');
+
+ $allowedRoles = array_map(
+ fn (string $role): StoreUserRole => StoreUserRole::from($role),
+ $roles,
+ );
+
+ abort_unless(in_array($storeUser->role, $allowedRoles, true), 403, 'Insufficient permissions.');
+
+ $request->attributes->set('store_user', $storeUser);
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/CustomerAuthenticate.php b/app/Http/Middleware/CustomerAuthenticate.php
new file mode 100644
index 00000000..7be5f811
--- /dev/null
+++ b/app/Http/Middleware/CustomerAuthenticate.php
@@ -0,0 +1,27 @@
+check()) {
+ $request->session()->put('url.intended', $request->fullUrl());
+
+ return redirect('/account/login');
+ }
+
+ return $next($request);
+ }
+}
diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php
new file mode 100644
index 00000000..40c33588
--- /dev/null
+++ b/app/Http/Middleware/ResolveStore.php
@@ -0,0 +1,86 @@
+resolveAdminStore($request)
+ : $this->resolveStorefrontStore($request);
+
+ app()->instance('current_store', $store);
+ view()->share('currentStore', $store);
+
+ return $next($request);
+ }
+
+ /**
+ * Resolve the store from the request hostname (storefront).
+ */
+ protected function resolveStorefrontStore(Request $request): Store
+ {
+ $hostname = $request->getHost();
+
+ $storeId = Cache::remember(
+ "store_domain:{$hostname}",
+ now()->addMinutes(5),
+ function () use ($hostname): ?int {
+ $id = StoreDomain::query()->where('hostname', $hostname)->value('store_id');
+
+ return $id === null ? null : (int) $id;
+ },
+ );
+
+ abort_if($storeId === null, 404, 'Store not found.');
+
+ $store = Store::query()->find($storeId);
+
+ abort_if($store === null, 404, 'Store not found.');
+ abort_if($store->status === StoreStatus::Suspended, 503, 'This store is currently unavailable.');
+
+ return $store;
+ }
+
+ /**
+ * Resolve the store from the session (admin panel).
+ */
+ protected function resolveAdminStore(Request $request): Store
+ {
+ $storeId = $request->session()->get('current_store_id');
+ $user = $request->user();
+
+ abort_if($storeId === null || $user === null, 403, 'You do not have access to this store.');
+
+ $hasMembership = StoreUser::query()
+ ->where('store_id', $storeId)
+ ->where('user_id', $user->getKey())
+ ->exists();
+
+ abort_unless($hasMembership, 403, 'You do not have access to this store.');
+
+ $store = Store::query()->find($storeId);
+
+ abort_if($store === null, 403, 'You do not have access to this store.');
+ abort_if($store->status === StoreStatus::Suspended, 403, 'This store is currently unavailable.');
+
+ return $store;
+ }
+}
diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php
new file mode 100644
index 00000000..e863bbef
--- /dev/null
+++ b/app/Models/Concerns/BelongsToStore.php
@@ -0,0 +1,24 @@
+getAttribute('store_id')) && app()->bound('current_store')) {
+ $model->setAttribute('store_id', app('current_store')->getKey());
+ }
+ });
+ }
+}
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
new file mode 100644
index 00000000..e16dd3f0
--- /dev/null
+++ b/app/Models/Customer.php
@@ -0,0 +1,57 @@
+ */
+ use BelongsToStore, HasFactory, Notifiable;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'email',
+ 'password_hash',
+ 'name',
+ 'marketing_opt_in',
+ ];
+
+ /**
+ * The attributes that should be hidden for serialization.
+ *
+ * @var list
+ */
+ protected $hidden = [
+ 'password_hash',
+ 'remember_token',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'password_hash' => 'hashed',
+ 'marketing_opt_in' => 'boolean',
+ ];
+ }
+
+ /**
+ * Get the password for authentication (custom column name).
+ */
+ public function getAuthPassword(): string
+ {
+ return $this->password_hash ?? '';
+ }
+}
diff --git a/app/Models/Organization.php b/app/Models/Organization.php
new file mode 100644
index 00000000..d26c7771
--- /dev/null
+++ b/app/Models/Organization.php
@@ -0,0 +1,33 @@
+ */
+ use HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'name',
+ 'billing_email',
+ ];
+
+ /**
+ * Get the stores owned by the organization.
+ *
+ * @return HasMany
+ */
+ public function stores(): HasMany
+ {
+ return $this->hasMany(Store::class);
+ }
+}
diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php
new file mode 100644
index 00000000..a1895ebe
--- /dev/null
+++ b/app/Models/Scopes/StoreScope.php
@@ -0,0 +1,26 @@
+ $builder
+ */
+ public function apply(Builder $builder, Model $model): void
+ {
+ if (app()->bound('current_store')) {
+ $builder->where($model->qualifyColumn('store_id'), app('current_store')->getKey());
+ }
+ }
+}
diff --git a/app/Models/Store.php b/app/Models/Store.php
new file mode 100644
index 00000000..9875f813
--- /dev/null
+++ b/app/Models/Store.php
@@ -0,0 +1,86 @@
+ */
+ use HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'organization_id',
+ 'name',
+ 'handle',
+ 'status',
+ 'default_currency',
+ 'default_locale',
+ 'timezone',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => StoreStatus::class,
+ ];
+ }
+
+ /**
+ * Get the organization that owns the store.
+ *
+ * @return BelongsTo
+ */
+ public function organization(): BelongsTo
+ {
+ return $this->belongsTo(Organization::class);
+ }
+
+ /**
+ * Get the domains attached to the store.
+ *
+ * @return HasMany
+ */
+ public function domains(): HasMany
+ {
+ return $this->hasMany(StoreDomain::class);
+ }
+
+ /**
+ * Get the admin users linked to the store.
+ *
+ * @return BelongsToMany
+ */
+ public function users(): BelongsToMany
+ {
+ return $this->belongsToMany(User::class, 'store_users')
+ ->withPivot('role')
+ ->using(StoreUser::class);
+ }
+
+ /**
+ * Get the settings bag for the store.
+ *
+ * @return HasOne
+ */
+ public function settings(): HasOne
+ {
+ return $this->hasOne(StoreSettings::class);
+ }
+}
diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php
new file mode 100644
index 00000000..97e6258d
--- /dev/null
+++ b/app/Models/StoreDomain.php
@@ -0,0 +1,56 @@
+ */
+ use HasFactory;
+
+ /**
+ * The store_domains table only has created_at, no updated_at.
+ */
+ public const UPDATED_AT = null;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'hostname',
+ 'type',
+ 'is_primary',
+ 'tls_mode',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => StoreDomainType::class,
+ 'is_primary' => 'boolean',
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Get the store that owns the domain.
+ *
+ * @return BelongsTo
+ */
+ public function store(): BelongsTo
+ {
+ return $this->belongsTo(Store::class);
+ }
+}
diff --git a/app/Models/StoreSettings.php b/app/Models/StoreSettings.php
new file mode 100644
index 00000000..d93c74fe
--- /dev/null
+++ b/app/Models/StoreSettings.php
@@ -0,0 +1,65 @@
+ */
+ use HasFactory;
+
+ /**
+ * The store_settings table only has updated_at, no created_at.
+ */
+ public const CREATED_AT = null;
+
+ /**
+ * The primary key is the owning store's id (one-to-one).
+ *
+ * @var string
+ */
+ protected $primaryKey = 'store_id';
+
+ /**
+ * The primary key is not auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'settings_json',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'settings_json' => 'array',
+ 'updated_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Get the store that owns the settings.
+ *
+ * @return BelongsTo
+ */
+ public function store(): BelongsTo
+ {
+ return $this->belongsTo(Store::class);
+ }
+}
diff --git a/app/Models/StoreUser.php b/app/Models/StoreUser.php
new file mode 100644
index 00000000..2c7f301c
--- /dev/null
+++ b/app/Models/StoreUser.php
@@ -0,0 +1,45 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table associated with the model.
+ *
+ * @var string
+ */
+ protected $table = 'store_users';
+
+ /**
+ * The store_users table only has created_at, no updated_at.
+ */
+ public const UPDATED_AT = null;
+
+ /**
+ * The store_users pivot manages its own timestamps on insert.
+ *
+ * @var bool
+ */
+ public $timestamps = true;
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'role' => StoreUserRole::class,
+ 'created_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app/Models/User.php b/app/Models/User.php
index 214bea4e..444f8bd3 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -2,17 +2,19 @@
namespace App\Models;
-// use Illuminate\Contracts\Auth\MustVerifyEmail;
+use App\Enums\StoreUserRole;
+use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
-use Laravel\Fortify\TwoFactorAuthenticatable;
+use Laravel\Sanctum\HasApiTokens;
-class User extends Authenticatable
+class User extends Authenticatable implements MustVerifyEmail
{
/** @use HasFactory<\Database\Factories\UserFactory> */
- use HasFactory, Notifiable, TwoFactorAuthenticatable;
+ use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
@@ -22,7 +24,8 @@ class User extends Authenticatable
protected $fillable = [
'name',
'email',
- 'password',
+ 'password_hash',
+ 'status',
];
/**
@@ -31,10 +34,10 @@ class User extends Authenticatable
* @var list
*/
protected $hidden = [
- 'password',
+ 'password_hash',
+ 'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
- 'remember_token',
];
/**
@@ -46,10 +49,47 @@ protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
- 'password' => 'hashed',
+ 'last_login_at' => 'datetime',
+ 'two_factor_confirmed_at' => 'datetime',
+ 'password_hash' => 'hashed',
];
}
+ /**
+ * Get the password for authentication (custom column name).
+ */
+ public function getAuthPassword(): string
+ {
+ return $this->password_hash;
+ }
+
+ /**
+ * Get the stores the user is a member of.
+ *
+ * @return BelongsToMany
+ */
+ public function stores(): BelongsToMany
+ {
+ return $this->belongsToMany(Store::class, 'store_users')
+ ->withPivot('role')
+ ->using(StoreUser::class);
+ }
+
+ /**
+ * Get the user's role for a given store, or null when the user has
+ * no access to that store.
+ */
+ public function roleForStore(Store $store): ?StoreUserRole
+ {
+ /** @var Store|null $membership */
+ $membership = $this->stores()->where('stores.id', $store->getKey())->first();
+
+ /** @var StoreUserRole|null $role */
+ $role = $membership?->pivot->role;
+
+ return $role;
+ }
+
/**
* Get the user's initials
*/
diff --git a/app/Policies/CollectionPolicy.php b/app/Policies/CollectionPolicy.php
new file mode 100644
index 00000000..b4706741
--- /dev/null
+++ b/app/Policies/CollectionPolicy.php
@@ -0,0 +1,41 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isAnyRole($user, $storeId);
+ }
+
+ public function view(User $user, Collection $collection): bool
+ {
+ return $this->isAnyRole($user, $collection->store_id);
+ }
+
+ public function create(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function update(User $user, Collection $collection): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $collection->store_id);
+ }
+
+ public function delete(User $user, Collection $collection): bool
+ {
+ return $this->isOwnerOrAdmin($user, $collection->store_id);
+ }
+}
diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php
new file mode 100644
index 00000000..96996090
--- /dev/null
+++ b/app/Policies/CustomerPolicy.php
@@ -0,0 +1,29 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isAnyRole($user, $storeId);
+ }
+
+ public function view(User $user, Customer $customer): bool
+ {
+ return $this->isAnyRole($user, $customer->store_id);
+ }
+
+ public function update(User $user, Customer $customer): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $customer->store_id);
+ }
+}
diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php
new file mode 100644
index 00000000..eedc15cb
--- /dev/null
+++ b/app/Policies/DiscountPolicy.php
@@ -0,0 +1,41 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isAnyRole($user, $storeId);
+ }
+
+ public function view(User $user, Discount $discount): bool
+ {
+ return $this->isAnyRole($user, $discount->store_id);
+ }
+
+ public function create(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function update(User $user, Discount $discount): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $discount->store_id);
+ }
+
+ public function delete(User $user, Discount $discount): bool
+ {
+ return $this->isOwnerOrAdmin($user, $discount->store_id);
+ }
+}
diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php
new file mode 100644
index 00000000..280ba53e
--- /dev/null
+++ b/app/Policies/FulfillmentPolicy.php
@@ -0,0 +1,32 @@
+isOwnerAdminOrStaff($user, $order->store_id);
+ }
+
+ public function update(User $user, Fulfillment $fulfillment): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id);
+ }
+
+ public function cancel(User $user, Fulfillment $fulfillment): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $fulfillment->order->store_id);
+ }
+}
diff --git a/app/Policies/NavigationMenuPolicy.php b/app/Policies/NavigationMenuPolicy.php
new file mode 100644
index 00000000..e07e9bd3
--- /dev/null
+++ b/app/Policies/NavigationMenuPolicy.php
@@ -0,0 +1,25 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function manage(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId);
+ }
+}
diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php
new file mode 100644
index 00000000..7824e14b
--- /dev/null
+++ b/app/Policies/OrderPolicy.php
@@ -0,0 +1,44 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isAnyRole($user, $storeId);
+ }
+
+ public function view(User $user, Order $order): bool
+ {
+ return $this->isAnyRole($user, $order->store_id);
+ }
+
+ public function update(User $user, Order $order): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $order->store_id);
+ }
+
+ public function cancel(User $user, Order $order): bool
+ {
+ return $this->isOwnerOrAdmin($user, $order->store_id);
+ }
+
+ public function createFulfillment(User $user, Order $order): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $order->store_id);
+ }
+
+ public function createRefund(User $user, Order $order): bool
+ {
+ return $this->isOwnerOrAdmin($user, $order->store_id);
+ }
+}
diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php
new file mode 100644
index 00000000..d5beb9aa
--- /dev/null
+++ b/app/Policies/PagePolicy.php
@@ -0,0 +1,41 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function view(User $user, Page $page): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $page->store_id);
+ }
+
+ public function create(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function update(User $user, Page $page): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $page->store_id);
+ }
+
+ public function delete(User $user, Page $page): bool
+ {
+ return $this->isOwnerOrAdmin($user, $page->store_id);
+ }
+}
diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php
new file mode 100644
index 00000000..daf709f9
--- /dev/null
+++ b/app/Policies/ProductPolicy.php
@@ -0,0 +1,51 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isAnyRole($user, $storeId);
+ }
+
+ public function view(User $user, Product $product): bool
+ {
+ return $this->isAnyRole($user, $product->store_id);
+ }
+
+ public function create(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId);
+ }
+
+ public function update(User $user, Product $product): bool
+ {
+ return $this->isOwnerAdminOrStaff($user, $product->store_id);
+ }
+
+ public function delete(User $user, Product $product): bool
+ {
+ return $this->isOwnerOrAdmin($user, $product->store_id);
+ }
+
+ public function archive(User $user, Product $product): bool
+ {
+ return $this->isOwnerOrAdmin($user, $product->store_id);
+ }
+
+ public function restore(User $user, Product $product): bool
+ {
+ return $this->isOwnerOrAdmin($user, $product->store_id);
+ }
+}
diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php
new file mode 100644
index 00000000..b8e381eb
--- /dev/null
+++ b/app/Policies/RefundPolicy.php
@@ -0,0 +1,21 @@
+isOwnerOrAdmin($user, $order->store_id);
+ }
+}
diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php
new file mode 100644
index 00000000..eb79584c
--- /dev/null
+++ b/app/Policies/StorePolicy.php
@@ -0,0 +1,28 @@
+isOwnerOrAdmin($user, $store->id);
+ }
+
+ public function updateSettings(User $user, Store $store): bool
+ {
+ return $this->isOwnerOrAdmin($user, $store->id);
+ }
+
+ public function delete(User $user, Store $store): bool
+ {
+ return $this->hasRole($user, $store->id, [StoreUserRole::Owner]);
+ }
+}
diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php
new file mode 100644
index 00000000..23f032cb
--- /dev/null
+++ b/app/Policies/ThemePolicy.php
@@ -0,0 +1,46 @@
+currentStoreId();
+
+ return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId);
+ }
+
+ public function view(User $user, Theme $theme): bool
+ {
+ return $this->isOwnerOrAdmin($user, $theme->store_id);
+ }
+
+ public function create(User $user): bool
+ {
+ $storeId = $this->currentStoreId();
+
+ return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId);
+ }
+
+ public function update(User $user, Theme $theme): bool
+ {
+ return $this->isOwnerOrAdmin($user, $theme->store_id);
+ }
+
+ public function delete(User $user, Theme $theme): bool
+ {
+ return $this->isOwnerOrAdmin($user, $theme->store_id);
+ }
+
+ public function publish(User $user, Theme $theme): bool
+ {
+ return $this->isOwnerOrAdmin($user, $theme->store_id);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 8a29e6f5..960131cf 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,9 +2,18 @@
namespace App\Providers;
+use App\Auth\CustomerUserProvider;
+use App\Enums\StoreUserRole;
+use App\Models\User;
use Carbon\CarbonImmutable;
+use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Contracts\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Gate;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -24,6 +33,9 @@ public function register(): void
public function boot(): void
{
$this->configureDefaults();
+ $this->configureAuth();
+ $this->configureGates();
+ $this->configureRateLimiting();
}
/**
@@ -47,4 +59,72 @@ protected function configureDefaults(): void
: null
);
}
+
+ /**
+ * Register the store-scoped customer user provider.
+ */
+ protected function configureAuth(): void
+ {
+ Auth::provider('customer', function (Application $app, array $config): CustomerUserProvider {
+ return new CustomerUserProvider($app['hash'], $config['model']);
+ });
+ }
+
+ /**
+ * Register gates for non-model operations (spec 06 §2.5).
+ */
+ protected function configureGates(): void
+ {
+ $ownerOrAdmin = [StoreUserRole::Owner, StoreUserRole::Admin];
+ $ownerAdminOrStaff = [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff];
+
+ Gate::define('manage-store-settings', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-staff', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-developers', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('view-analytics', fn (User $user): bool => $this->gateAllows($user, $ownerAdminOrStaff));
+ Gate::define('manage-shipping', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-taxes', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-search-settings', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-navigation', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ Gate::define('manage-apps', fn (User $user): bool => $this->gateAllows($user, $ownerOrAdmin));
+ }
+
+ /**
+ * Resolve the current store from the container and check the user's
+ * role membership against the given roles.
+ *
+ * @param array $roles
+ */
+ protected function gateAllows(User $user, array $roles): bool
+ {
+ if (! app()->bound('current_store')) {
+ return false;
+ }
+
+ $role = $user->roleForStore(app('current_store'));
+
+ return $role !== null && in_array($role, $roles, true);
+ }
+
+ /**
+ * Register the application's rate limiters (spec 06 §4.2).
+ */
+ protected function configureRateLimiting(): void
+ {
+ RateLimiter::for('login', fn (Request $request): Limit => Limit::perMinute(5)->by($request->ip()));
+
+ RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()));
+
+ RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120)->by($request->ip()));
+
+ RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10)->by(
+ $request->hasSession() ? $request->session()->getId() : $request->ip(),
+ ));
+
+ RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()));
+
+ RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60)->by($request->ip()));
+
+ RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100)->by($request->ip()));
+ }
}
diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php
new file mode 100644
index 00000000..d26b594f
--- /dev/null
+++ b/app/Traits/ChecksStoreRole.php
@@ -0,0 +1,68 @@
+where('store_id', $storeId)
+ ->where('user_id', $user->getKey())
+ ->value('role');
+
+ return $role === null ? null : StoreUserRole::from($role);
+ }
+
+ /**
+ * Determine whether the user's role for the store is in the given list.
+ *
+ * @param array $roles
+ */
+ protected function hasRole(User $user, int $storeId, array $roles): bool
+ {
+ $role = $this->getStoreRole($user, $storeId);
+
+ return $role !== null && in_array($role, $roles, true);
+ }
+
+ /**
+ * Determine whether the user is an owner or admin of the store.
+ */
+ protected function isOwnerOrAdmin(User $user, int $storeId): bool
+ {
+ return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]);
+ }
+
+ /**
+ * Determine whether the user is an owner, admin, or staff of the store.
+ */
+ protected function isOwnerAdminOrStaff(User $user, int $storeId): bool
+ {
+ return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin, StoreUserRole::Staff]);
+ }
+
+ /**
+ * Determine whether the user holds any role in the store.
+ */
+ protected function isAnyRole(User $user, int $storeId): bool
+ {
+ return $this->getStoreRole($user, $storeId) !== null;
+ }
+
+ /**
+ * Resolve the store id from the container-bound current store.
+ */
+ protected function currentStoreId(): ?int
+ {
+ return app()->bound('current_store') ? (int) app('current_store')->getKey() : null;
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index c1832766..286a5e97 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -1,17 +1,29 @@
withRouting(
web: __DIR__.'/../routes/web.php',
+ api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
+ then: function (): void {
+ Route::middleware('web')->group(base_path('routes/admin.php'));
+ },
)
->withMiddleware(function (Middleware $middleware): void {
- //
+ $middleware->alias([
+ 'store.resolve' => ResolveStore::class,
+ 'role.check' => CheckStoreRole::class,
+ 'auth.customer' => CustomerAuthenticate::class,
+ ]);
})
->withExceptions(function (Exceptions $exceptions): void {
//
diff --git a/composer.json b/composer.json
index 72e094db..6152b538 100644
--- a/composer.json
+++ b/composer.json
@@ -10,7 +10,6 @@
"license": "MIT",
"require": {
"php": "^8.2",
- "laravel/fortify": "^1.30",
"laravel/framework": "^12.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^2.10.1",
diff --git a/composer.lock b/composer.lock
index f3dac5e6..6cc60cd5 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,63 +4,8 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "842bb7ef40dbf5886b406e01f112bcc6",
+ "content-hash": "597dbb82d1dd1966a3c1dd10952c197d",
"packages": [
- {
- "name": "bacon/bacon-qr-code",
- "version": "v3.0.3",
- "source": {
- "type": "git",
- "url": "https://github.com/Bacon/BaconQrCode.git",
- "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/36a1cb2b81493fa5b82e50bf8068bf84d1542563",
- "reference": "36a1cb2b81493fa5b82e50bf8068bf84d1542563",
- "shasum": ""
- },
- "require": {
- "dasprid/enum": "^1.0.3",
- "ext-iconv": "*",
- "php": "^8.1"
- },
- "require-dev": {
- "phly/keep-a-changelog": "^2.12",
- "phpunit/phpunit": "^10.5.11 || ^11.0.4",
- "spatie/phpunit-snapshot-assertions": "^5.1.5",
- "spatie/pixelmatch-php": "^1.2.0",
- "squizlabs/php_codesniffer": "^3.9"
- },
- "suggest": {
- "ext-imagick": "to generate QR code images"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "BaconQrCode\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Ben Scholzen 'DASPRiD'",
- "email": "mail@dasprids.de",
- "homepage": "https://dasprids.de/",
- "role": "Developer"
- }
- ],
- "description": "BaconQrCode is a QR code generator for PHP.",
- "homepage": "https://github.com/Bacon/BaconQrCode",
- "support": {
- "issues": "https://github.com/Bacon/BaconQrCode/issues",
- "source": "https://github.com/Bacon/BaconQrCode/tree/v3.0.3"
- },
- "time": "2025-11-19T17:15:36+00:00"
- },
{
"name": "brick/math",
"version": "0.14.8",
@@ -190,56 +135,6 @@
],
"time": "2024-02-09T16:56:22+00:00"
},
- {
- "name": "dasprid/enum",
- "version": "1.0.7",
- "source": {
- "type": "git",
- "url": "https://github.com/DASPRiD/Enum.git",
- "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
- "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
- "shasum": ""
- },
- "require": {
- "php": ">=7.1 <9.0"
- },
- "require-dev": {
- "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
- "squizlabs/php_codesniffer": "*"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "DASPRiD\\Enum\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "BSD-2-Clause"
- ],
- "authors": [
- {
- "name": "Ben Scholzen 'DASPRiD'",
- "email": "mail@dasprids.de",
- "homepage": "https://dasprids.de/",
- "role": "Developer"
- }
- ],
- "description": "PHP 7.1 enum implementation",
- "keywords": [
- "enum",
- "map"
- ],
- "support": {
- "issues": "https://github.com/DASPRiD/Enum/issues",
- "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
- },
- "time": "2025-09-16T12:23:56+00:00"
- },
{
"name": "dflydev/dot-access-data",
"version": "v3.0.3",
@@ -1157,69 +1052,6 @@
],
"time": "2025-08-22T14:27:06+00:00"
},
- {
- "name": "laravel/fortify",
- "version": "v1.34.1",
- "source": {
- "type": "git",
- "url": "https://github.com/laravel/fortify.git",
- "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/laravel/fortify/zipball/412575e9c0cb21d49a30b7045ad4902019f538c2",
- "reference": "412575e9c0cb21d49a30b7045ad4902019f538c2",
- "shasum": ""
- },
- "require": {
- "bacon/bacon-qr-code": "^3.0",
- "ext-json": "*",
- "illuminate/console": "^10.0|^11.0|^12.0|^13.0",
- "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
- "php": "^8.1",
- "pragmarx/google2fa": "^9.0"
- },
- "require-dev": {
- "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
- "phpstan/phpstan": "^1.10"
- },
- "type": "library",
- "extra": {
- "laravel": {
- "providers": [
- "Laravel\\Fortify\\FortifyServiceProvider"
- ]
- },
- "branch-alias": {
- "dev-master": "1.x-dev"
- }
- },
- "autoload": {
- "psr-4": {
- "Laravel\\Fortify\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Taylor Otwell",
- "email": "taylor@laravel.com"
- }
- ],
- "description": "Backend controllers and scaffolding for Laravel authentication.",
- "keywords": [
- "auth",
- "laravel"
- ],
- "support": {
- "issues": "https://github.com/laravel/fortify/issues",
- "source": "https://github.com/laravel/fortify"
- },
- "time": "2026-02-03T06:55:55+00:00"
- },
{
"name": "laravel/framework",
"version": "v12.51.0",
@@ -2899,75 +2731,6 @@
],
"time": "2025-11-20T02:34:59+00:00"
},
- {
- "name": "paragonie/constant_time_encoding",
- "version": "v3.1.3",
- "source": {
- "type": "git",
- "url": "https://github.com/paragonie/constant_time_encoding.git",
- "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
- "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
- "shasum": ""
- },
- "require": {
- "php": "^8"
- },
- "require-dev": {
- "infection/infection": "^0",
- "nikic/php-fuzzer": "^0",
- "phpunit/phpunit": "^9|^10|^11",
- "vimeo/psalm": "^4|^5|^6"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "ParagonIE\\ConstantTime\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Paragon Initiative Enterprises",
- "email": "security@paragonie.com",
- "homepage": "https://paragonie.com",
- "role": "Maintainer"
- },
- {
- "name": "Steve 'Sc00bz' Thomas",
- "email": "steve@tobtu.com",
- "homepage": "https://www.tobtu.com",
- "role": "Original Developer"
- }
- ],
- "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
- "keywords": [
- "base16",
- "base32",
- "base32_decode",
- "base32_encode",
- "base64",
- "base64_decode",
- "base64_encode",
- "bin2hex",
- "encoding",
- "hex",
- "hex2bin",
- "rfc4648"
- ],
- "support": {
- "email": "info@paragonie.com",
- "issues": "https://github.com/paragonie/constant_time_encoding/issues",
- "source": "https://github.com/paragonie/constant_time_encoding"
- },
- "time": "2025-09-24T15:06:41+00:00"
- },
{
"name": "phpoption/phpoption",
"version": "1.9.5",
@@ -3043,58 +2806,6 @@
],
"time": "2025-12-27T19:41:33+00:00"
},
- {
- "name": "pragmarx/google2fa",
- "version": "v9.0.0",
- "source": {
- "type": "git",
- "url": "https://github.com/antonioribeiro/google2fa.git",
- "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf",
- "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf",
- "shasum": ""
- },
- "require": {
- "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0",
- "php": "^7.1|^8.0"
- },
- "require-dev": {
- "phpstan/phpstan": "^1.9",
- "phpunit/phpunit": "^7.5.15|^8.5|^9.0"
- },
- "type": "library",
- "autoload": {
- "psr-4": {
- "PragmaRX\\Google2FA\\": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Antonio Carlos Ribeiro",
- "email": "acr@antoniocarlosribeiro.com",
- "role": "Creator & Designer"
- }
- ],
- "description": "A One Time Password Authentication package, compatible with Google Authenticator.",
- "keywords": [
- "2fa",
- "Authentication",
- "Two Factor Authentication",
- "google2fa"
- ],
- "support": {
- "issues": "https://github.com/antonioribeiro/google2fa/issues",
- "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0"
- },
- "time": "2025-09-19T22:51:08+00:00"
- },
{
"name": "psr/clock",
"version": "1.0.0",
diff --git a/config/auth.php b/config/auth.php
index 7d1eb0de..c7e1a387 100644
--- a/config/auth.php
+++ b/config/auth.php
@@ -40,6 +40,11 @@
'driver' => 'session',
'provider' => 'users',
],
+
+ 'customer' => [
+ 'driver' => 'session',
+ 'provider' => 'customers',
+ ],
],
/*
@@ -65,10 +70,10 @@
'model' => env('AUTH_MODEL', App\Models\User::class),
],
- // 'users' => [
- // 'driver' => 'database',
- // 'table' => 'users',
- // ],
+ 'customers' => [
+ 'driver' => 'customer',
+ 'model' => App\Models\Customer::class,
+ ],
],
/*
@@ -97,6 +102,13 @@
'expire' => 60,
'throttle' => 60,
],
+
+ 'customers' => [
+ 'provider' => 'customers',
+ 'table' => 'customer_password_reset_tokens',
+ 'expire' => 60,
+ 'throttle' => 60,
+ ],
],
/*
diff --git a/config/cors.php b/config/cors.php
new file mode 100644
index 00000000..19f67154
--- /dev/null
+++ b/config/cors.php
@@ -0,0 +1,31 @@
+ ['api/*', 'sanctum/csrf-cookie'],
+
+ 'allowed_methods' => ['*'],
+
+ 'allowed_origins' => [env('FRONTEND_URL', '*')],
+
+ 'allowed_origins_patterns' => [],
+
+ 'allowed_headers' => ['*'],
+
+ 'exposed_headers' => ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'Retry-After'],
+
+ 'max_age' => 0,
+
+ 'supports_credentials' => true,
+
+];
diff --git a/config/database.php b/config/database.php
index df933e7f..ecfaacf9 100644
--- a/config/database.php
+++ b/config/database.php
@@ -37,9 +37,9 @@
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
- 'busy_timeout' => null,
- 'journal_mode' => null,
- 'synchronous' => null,
+ 'busy_timeout' => 5000,
+ 'journal_mode' => 'wal',
+ 'synchronous' => 'normal',
'transaction_mode' => 'DEFERRED',
],
diff --git a/config/logging.php b/config/logging.php
index 9e998a49..5c052a3b 100644
--- a/config/logging.php
+++ b/config/logging.php
@@ -73,6 +73,23 @@
'replace_placeholders' => true,
],
+ 'json' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/json.log'),
+ 'level' => env('LOG_LEVEL', 'debug'),
+ 'days' => 14,
+ 'formatter' => Monolog\Formatter\JsonFormatter::class,
+ 'replace_placeholders' => true,
+ ],
+
+ 'audit' => [
+ 'driver' => 'daily',
+ 'path' => storage_path('logs/audit.log'),
+ 'level' => 'info',
+ 'days' => 90,
+ 'replace_placeholders' => true,
+ ],
+
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php
new file mode 100644
index 00000000..419437f4
--- /dev/null
+++ b/database/factories/CustomerFactory.php
@@ -0,0 +1,44 @@
+
+ */
+class CustomerFactory extends Factory
+{
+ /**
+ * The current password being used by the factory.
+ */
+ protected static ?string $password;
+
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'email' => fake()->unique()->safeEmail(),
+ 'password_hash' => static::$password ??= Hash::make('password'),
+ 'name' => fake()->name(),
+ 'marketing_opt_in' => false,
+ ];
+ }
+
+ /**
+ * Indicate that the customer checked out as a guest (no password).
+ */
+ public function guest(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'password_hash' => null,
+ ]);
+ }
+}
diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php
new file mode 100644
index 00000000..5f550971
--- /dev/null
+++ b/database/factories/OrganizationFactory.php
@@ -0,0 +1,24 @@
+
+ */
+class OrganizationFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'name' => fake()->company(),
+ 'billing_email' => fake()->unique()->safeEmail(),
+ ];
+ }
+}
diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php
new file mode 100644
index 00000000..ed419e23
--- /dev/null
+++ b/database/factories/StoreDomainFactory.php
@@ -0,0 +1,29 @@
+
+ */
+class StoreDomainFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'hostname' => fake()->unique()->domainName(),
+ 'type' => StoreDomainType::Storefront,
+ 'is_primary' => false,
+ 'tls_mode' => 'managed',
+ ];
+ }
+}
diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php
new file mode 100644
index 00000000..96c78d0f
--- /dev/null
+++ b/database/factories/StoreFactory.php
@@ -0,0 +1,31 @@
+
+ */
+class StoreFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'organization_id' => Organization::factory(),
+ 'name' => fake()->company(),
+ 'handle' => fake()->unique()->slug(2),
+ 'status' => StoreStatus::Active,
+ 'default_currency' => 'USD',
+ 'default_locale' => 'en',
+ 'timezone' => 'UTC',
+ ];
+ }
+}
diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php
new file mode 100644
index 00000000..9a5191e4
--- /dev/null
+++ b/database/factories/StoreSettingsFactory.php
@@ -0,0 +1,25 @@
+
+ */
+class StoreSettingsFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'settings_json' => [],
+ ];
+ }
+}
diff --git a/database/factories/StoreUserFactory.php b/database/factories/StoreUserFactory.php
new file mode 100644
index 00000000..9e696267
--- /dev/null
+++ b/database/factories/StoreUserFactory.php
@@ -0,0 +1,28 @@
+
+ */
+class StoreUserFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'user_id' => User::factory(),
+ 'role' => StoreUserRole::Staff,
+ ];
+ }
+}
diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php
index 80da5ac7..32254b05 100644
--- a/database/factories/UserFactory.php
+++ b/database/factories/UserFactory.php
@@ -7,7 +7,7 @@
use Illuminate\Support\Str;
/**
- * @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\User>
+ * @extends Factory<\App\Models\User>
*/
class UserFactory extends Factory
{
@@ -27,7 +27,8 @@ public function definition(): array
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
- 'password' => static::$password ??= Hash::make('password'),
+ 'password_hash' => static::$password ??= Hash::make('password'),
+ 'status' => 'active',
'remember_token' => Str::random(10),
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php
index 05fb5d9e..1396dae8 100644
--- a/database/migrations/0001_01_01_000000_create_users_table.php
+++ b/database/migrations/0001_01_01_000000_create_users_table.php
@@ -13,12 +13,20 @@ public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
- $table->string('name');
- $table->string('email')->unique();
+ $table->text('email');
+ $table->text('password_hash');
+ $table->text('name');
+ $table->text('status')->default('active');
$table->timestamp('email_verified_at')->nullable();
- $table->string('password');
- $table->rememberToken();
+ $table->timestamp('last_login_at')->nullable();
+ $table->text('two_factor_secret')->nullable();
+ $table->text('two_factor_recovery_codes')->nullable();
+ $table->timestamp('two_factor_confirmed_at')->nullable();
+ $table->text('remember_token')->nullable();
$table->timestamps();
+
+ $table->unique('email', 'idx_users_email');
+ $table->index('status', 'idx_users_status');
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
diff --git a/database/migrations/2026_01_01_000001_create_organizations_table.php b/database/migrations/2026_01_01_000001_create_organizations_table.php
new file mode 100644
index 00000000..bd71e3da
--- /dev/null
+++ b/database/migrations/2026_01_01_000001_create_organizations_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->text('name');
+ $table->text('billing_email');
+ $table->timestamps();
+
+ $table->index('billing_email', 'idx_organizations_billing_email');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('organizations');
+ }
+};
diff --git a/database/migrations/2026_01_01_000002_create_apps_table.php b/database/migrations/2026_01_01_000002_create_apps_table.php
new file mode 100644
index 00000000..75f3cc24
--- /dev/null
+++ b/database/migrations/2026_01_01_000002_create_apps_table.php
@@ -0,0 +1,31 @@
+id();
+ $table->text('name');
+ $table->text('status')->default('active');
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('status', 'idx_apps_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('apps');
+ }
+};
diff --git a/database/migrations/2026_01_01_000003_create_stores_table.php b/database/migrations/2026_01_01_000003_create_stores_table.php
new file mode 100644
index 00000000..76984da2
--- /dev/null
+++ b/database/migrations/2026_01_01_000003_create_stores_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('organization_id')->constrained()->cascadeOnDelete();
+ $table->text('name');
+ $table->text('handle');
+ $table->text('status')->default('active');
+ $table->text('default_currency')->default('USD');
+ $table->text('default_locale')->default('en');
+ $table->text('timezone')->default('UTC');
+ $table->timestamps();
+
+ $table->unique('handle', 'idx_stores_handle');
+ $table->index('organization_id', 'idx_stores_organization_id');
+ $table->index('status', 'idx_stores_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('stores');
+ }
+};
diff --git a/database/migrations/2026_01_01_000004_create_store_domains_table.php b/database/migrations/2026_01_01_000004_create_store_domains_table.php
new file mode 100644
index 00000000..e4792d91
--- /dev/null
+++ b/database/migrations/2026_01_01_000004_create_store_domains_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('hostname');
+ $table->text('type')->default('storefront');
+ $table->integer('is_primary')->default(0);
+ $table->text('tls_mode')->default('managed');
+ $table->timestamp('created_at')->nullable();
+
+ $table->unique('hostname', 'idx_store_domains_hostname');
+ $table->index('store_id', 'idx_store_domains_store_id');
+ $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('store_domains');
+ }
+};
diff --git a/database/migrations/2026_01_01_000005_create_store_users_table.php b/database/migrations/2026_01_01_000005_create_store_users_table.php
new file mode 100644
index 00000000..83cd6e25
--- /dev/null
+++ b/database/migrations/2026_01_01_000005_create_store_users_table.php
@@ -0,0 +1,33 @@
+foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('user_id')->constrained()->cascadeOnDelete();
+ $table->text('role')->default('staff');
+ $table->timestamp('created_at')->nullable();
+
+ $table->primary(['store_id', 'user_id']);
+ $table->index('user_id', 'idx_store_users_user_id');
+ $table->index(['store_id', 'role'], 'idx_store_users_role');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('store_users');
+ }
+};
diff --git a/database/migrations/2026_01_01_000006_create_store_settings_table.php b/database/migrations/2026_01_01_000006_create_store_settings_table.php
new file mode 100644
index 00000000..33fc0d5f
--- /dev/null
+++ b/database/migrations/2026_01_01_000006_create_store_settings_table.php
@@ -0,0 +1,28 @@
+foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+ $table->text('settings_json')->default('{}');
+ $table->timestamp('updated_at')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('store_settings');
+ }
+};
diff --git a/database/migrations/2026_01_01_000007_create_customers_table.php b/database/migrations/2026_01_01_000007_create_customers_table.php
new file mode 100644
index 00000000..512f23ea
--- /dev/null
+++ b/database/migrations/2026_01_01_000007_create_customers_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('email');
+ $table->text('password_hash')->nullable();
+ $table->text('name')->nullable();
+ $table->integer('marketing_opt_in')->default(0);
+ $table->timestamps();
+
+ $table->unique(['store_id', 'email'], 'idx_customers_store_email');
+ $table->index('store_id', 'idx_customers_store_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('customers');
+ }
+};
diff --git a/database/migrations/2026_01_01_000008_create_themes_table.php b/database/migrations/2026_01_01_000008_create_themes_table.php
new file mode 100644
index 00000000..8bda1663
--- /dev/null
+++ b/database/migrations/2026_01_01_000008_create_themes_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('name');
+ $table->text('version')->nullable();
+ $table->text('status')->default('draft');
+ $table->timestamp('published_at')->nullable();
+ $table->timestamps();
+
+ $table->index('store_id', 'idx_themes_store_id');
+ $table->index(['store_id', 'status'], 'idx_themes_store_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('themes');
+ }
+};
diff --git a/database/migrations/2026_01_01_000009_create_pages_table.php b/database/migrations/2026_01_01_000009_create_pages_table.php
new file mode 100644
index 00000000..8bacde94
--- /dev/null
+++ b/database/migrations/2026_01_01_000009_create_pages_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('title');
+ $table->text('handle');
+ $table->text('body_html')->nullable();
+ $table->text('status')->default('draft');
+ $table->timestamp('published_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['store_id', 'handle'], 'idx_pages_store_handle');
+ $table->index('store_id', 'idx_pages_store_id');
+ $table->index(['store_id', 'status'], 'idx_pages_store_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('pages');
+ }
+};
diff --git a/database/migrations/2026_01_01_000010_create_navigation_menus_table.php b/database/migrations/2026_01_01_000010_create_navigation_menus_table.php
new file mode 100644
index 00000000..2e743d08
--- /dev/null
+++ b/database/migrations/2026_01_01_000010_create_navigation_menus_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('handle');
+ $table->text('title');
+ $table->timestamps();
+
+ $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle');
+ $table->index('store_id', 'idx_navigation_menus_store_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('navigation_menus');
+ }
+};
diff --git a/database/migrations/2026_01_01_000011_create_search_settings_table.php b/database/migrations/2026_01_01_000011_create_search_settings_table.php
new file mode 100644
index 00000000..a566052f
--- /dev/null
+++ b/database/migrations/2026_01_01_000011_create_search_settings_table.php
@@ -0,0 +1,29 @@
+foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+ $table->text('synonyms_json')->default('[]');
+ $table->text('stop_words_json')->default('[]');
+ $table->timestamp('updated_at')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('search_settings');
+ }
+};
diff --git a/database/migrations/2026_01_01_000012_create_shipping_zones_table.php b/database/migrations/2026_01_01_000012_create_shipping_zones_table.php
new file mode 100644
index 00000000..a7926952
--- /dev/null
+++ b/database/migrations/2026_01_01_000012_create_shipping_zones_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('name');
+ $table->text('countries_json')->default('[]');
+ $table->text('regions_json')->default('[]');
+
+ $table->index('store_id', 'idx_shipping_zones_store_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('shipping_zones');
+ }
+};
diff --git a/database/migrations/2026_01_01_000013_create_tax_settings_table.php b/database/migrations/2026_01_01_000013_create_tax_settings_table.php
new file mode 100644
index 00000000..648559be
--- /dev/null
+++ b/database/migrations/2026_01_01_000013_create_tax_settings_table.php
@@ -0,0 +1,30 @@
+foreignId('store_id')->primary()->constrained()->cascadeOnDelete();
+ $table->text('mode')->default('manual');
+ $table->text('provider')->default('none');
+ $table->integer('prices_include_tax')->default(0);
+ $table->text('config_json')->default('{}');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('tax_settings');
+ }
+};
diff --git a/database/migrations/2026_01_01_000014_create_discounts_table.php b/database/migrations/2026_01_01_000014_create_discounts_table.php
new file mode 100644
index 00000000..637c9b38
--- /dev/null
+++ b/database/migrations/2026_01_01_000014_create_discounts_table.php
@@ -0,0 +1,43 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('type')->default('code');
+ $table->text('code')->nullable();
+ $table->text('value_type');
+ $table->integer('value_amount')->default(0);
+ $table->text('starts_at');
+ $table->text('ends_at')->nullable();
+ $table->integer('usage_limit')->nullable();
+ $table->integer('usage_count')->default(0);
+ $table->text('rules_json')->default('{}');
+ $table->text('status')->default('active');
+ $table->timestamps();
+
+ $table->unique(['store_id', 'code'], 'idx_discounts_store_code');
+ $table->index('store_id', 'idx_discounts_store_id');
+ $table->index(['store_id', 'status'], 'idx_discounts_store_status');
+ $table->index(['store_id', 'type'], 'idx_discounts_store_type');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('discounts');
+ }
+};
diff --git a/database/migrations/2026_01_01_000015_create_products_table.php b/database/migrations/2026_01_01_000015_create_products_table.php
new file mode 100644
index 00000000..d5a11189
--- /dev/null
+++ b/database/migrations/2026_01_01_000015_create_products_table.php
@@ -0,0 +1,43 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('title');
+ $table->text('handle');
+ $table->text('status')->default('draft');
+ $table->text('description_html')->nullable();
+ $table->text('vendor')->nullable();
+ $table->text('product_type')->nullable();
+ $table->text('tags')->default('[]');
+ $table->timestamp('published_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['store_id', 'handle'], 'idx_products_store_handle');
+ $table->index('store_id', 'idx_products_store_id');
+ $table->index(['store_id', 'status'], 'idx_products_store_status');
+ $table->index(['store_id', 'published_at'], 'idx_products_published_at');
+ $table->index(['store_id', 'vendor'], 'idx_products_vendor');
+ $table->index(['store_id', 'product_type'], 'idx_products_product_type');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('products');
+ }
+};
diff --git a/database/migrations/2026_01_01_000016_create_collections_table.php b/database/migrations/2026_01_01_000016_create_collections_table.php
new file mode 100644
index 00000000..4af38034
--- /dev/null
+++ b/database/migrations/2026_01_01_000016_create_collections_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('title');
+ $table->text('handle');
+ $table->text('description_html')->nullable();
+ $table->text('type')->default('manual');
+ $table->text('status')->default('active');
+ $table->timestamps();
+
+ $table->unique(['store_id', 'handle'], 'idx_collections_store_handle');
+ $table->index('store_id', 'idx_collections_store_id');
+ $table->index(['store_id', 'status'], 'idx_collections_store_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('collections');
+ }
+};
diff --git a/database/migrations/2026_01_01_000017_create_app_installations_table.php b/database/migrations/2026_01_01_000017_create_app_installations_table.php
new file mode 100644
index 00000000..92939640
--- /dev/null
+++ b/database/migrations/2026_01_01_000017_create_app_installations_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('app_id')->constrained()->cascadeOnDelete();
+ $table->text('scopes_json')->default('[]');
+ $table->text('status')->default('active');
+ $table->timestamp('installed_at')->nullable();
+
+ $table->unique(['store_id', 'app_id'], 'idx_app_installations_store_app');
+ $table->index('store_id', 'idx_app_installations_store_id');
+ $table->index('app_id', 'idx_app_installations_app_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('app_installations');
+ }
+};
diff --git a/database/migrations/2026_01_01_000018_create_oauth_clients_table.php b/database/migrations/2026_01_01_000018_create_oauth_clients_table.php
new file mode 100644
index 00000000..7cc08c9a
--- /dev/null
+++ b/database/migrations/2026_01_01_000018_create_oauth_clients_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('app_id')->constrained()->cascadeOnDelete();
+ $table->text('client_id');
+ $table->text('client_secret_encrypted');
+ $table->text('redirect_uris_json')->default('[]');
+
+ $table->unique('client_id', 'idx_oauth_clients_client_id');
+ $table->index('app_id', 'idx_oauth_clients_app_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('oauth_clients');
+ }
+};
diff --git a/database/migrations/2026_01_01_000019_create_product_options_table.php b/database/migrations/2026_01_01_000019_create_product_options_table.php
new file mode 100644
index 00000000..7ddecac7
--- /dev/null
+++ b/database/migrations/2026_01_01_000019_create_product_options_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->text('name');
+ $table->integer('position')->default(0);
+
+ $table->index('product_id', 'idx_product_options_product_id');
+ $table->unique(['product_id', 'position'], 'idx_product_options_product_position');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('product_options');
+ }
+};
diff --git a/database/migrations/2026_01_01_000020_create_product_variants_table.php b/database/migrations/2026_01_01_000020_create_product_variants_table.php
new file mode 100644
index 00000000..089cd03e
--- /dev/null
+++ b/database/migrations/2026_01_01_000020_create_product_variants_table.php
@@ -0,0 +1,44 @@
+id();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->text('sku')->nullable();
+ $table->text('barcode')->nullable();
+ $table->integer('price_amount')->default(0);
+ $table->integer('compare_at_amount')->nullable();
+ $table->text('currency')->default('USD');
+ $table->integer('weight_g')->nullable();
+ $table->integer('requires_shipping')->default(1);
+ $table->integer('is_default')->default(0);
+ $table->integer('position')->default(0);
+ $table->text('status')->default('active');
+ $table->timestamps();
+
+ $table->index('product_id', 'idx_product_variants_product_id');
+ $table->index('sku', 'idx_product_variants_sku');
+ $table->index('barcode', 'idx_product_variants_barcode');
+ $table->index(['product_id', 'position'], 'idx_product_variants_product_position');
+ $table->index(['product_id', 'is_default'], 'idx_product_variants_product_default');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('product_variants');
+ }
+};
diff --git a/database/migrations/2026_01_01_000021_create_product_media_table.php b/database/migrations/2026_01_01_000021_create_product_media_table.php
new file mode 100644
index 00000000..4da52cc2
--- /dev/null
+++ b/database/migrations/2026_01_01_000021_create_product_media_table.php
@@ -0,0 +1,41 @@
+id();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->text('type')->default('image');
+ $table->text('storage_key');
+ $table->text('alt_text')->nullable();
+ $table->integer('width')->nullable();
+ $table->integer('height')->nullable();
+ $table->text('mime_type')->nullable();
+ $table->integer('byte_size')->nullable();
+ $table->integer('position')->default(0);
+ $table->text('status')->default('processing');
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('product_id', 'idx_product_media_product_id');
+ $table->index(['product_id', 'position'], 'idx_product_media_product_position');
+ $table->index('status', 'idx_product_media_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('product_media');
+ }
+};
diff --git a/database/migrations/2026_01_01_000022_create_collection_products_table.php b/database/migrations/2026_01_01_000022_create_collection_products_table.php
new file mode 100644
index 00000000..93592eb4
--- /dev/null
+++ b/database/migrations/2026_01_01_000022_create_collection_products_table.php
@@ -0,0 +1,32 @@
+foreignId('collection_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('product_id')->constrained()->cascadeOnDelete();
+ $table->integer('position')->default(0);
+
+ $table->primary(['collection_id', 'product_id']);
+ $table->index('product_id', 'idx_collection_products_product_id');
+ $table->index(['collection_id', 'position'], 'idx_collection_products_position');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('collection_products');
+ }
+};
diff --git a/database/migrations/2026_01_01_000023_create_customer_addresses_table.php b/database/migrations/2026_01_01_000023_create_customer_addresses_table.php
new file mode 100644
index 00000000..3c30f55e
--- /dev/null
+++ b/database/migrations/2026_01_01_000023_create_customer_addresses_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->foreignId('customer_id')->constrained()->cascadeOnDelete();
+ $table->text('label')->nullable();
+ $table->text('address_json')->default('{}');
+ $table->integer('is_default')->default(0);
+
+ $table->index('customer_id', 'idx_customer_addresses_customer_id');
+ $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('customer_addresses');
+ }
+};
diff --git a/database/migrations/2026_01_01_000024_create_carts_table.php b/database/migrations/2026_01_01_000024_create_carts_table.php
new file mode 100644
index 00000000..ea75326b
--- /dev/null
+++ b/database/migrations/2026_01_01_000024_create_carts_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+ $table->text('currency')->default('USD');
+ $table->integer('cart_version')->default(1);
+ $table->text('status')->default('active');
+ $table->timestamps();
+
+ $table->index('store_id', 'idx_carts_store_id');
+ $table->index('customer_id', 'idx_carts_customer_id');
+ $table->index(['store_id', 'status'], 'idx_carts_store_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('carts');
+ }
+};
diff --git a/database/migrations/2026_01_01_000025_create_navigation_items_table.php b/database/migrations/2026_01_01_000025_create_navigation_items_table.php
new file mode 100644
index 00000000..341bec80
--- /dev/null
+++ b/database/migrations/2026_01_01_000025_create_navigation_items_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete();
+ $table->text('type')->default('link');
+ $table->text('label');
+ $table->text('url')->nullable();
+ $table->integer('resource_id')->nullable();
+ $table->integer('position')->default(0);
+
+ $table->index('menu_id', 'idx_navigation_items_menu_id');
+ $table->index(['menu_id', 'position'], 'idx_navigation_items_menu_position');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('navigation_items');
+ }
+};
diff --git a/database/migrations/2026_01_01_000026_create_theme_files_table.php b/database/migrations/2026_01_01_000026_create_theme_files_table.php
new file mode 100644
index 00000000..5dc04b88
--- /dev/null
+++ b/database/migrations/2026_01_01_000026_create_theme_files_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('theme_id')->constrained()->cascadeOnDelete();
+ $table->text('path');
+ $table->text('storage_key');
+ $table->text('sha256');
+ $table->integer('byte_size')->default(0);
+
+ $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path');
+ $table->index('theme_id', 'idx_theme_files_theme_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('theme_files');
+ }
+};
diff --git a/database/migrations/2026_01_01_000027_create_theme_settings_table.php b/database/migrations/2026_01_01_000027_create_theme_settings_table.php
new file mode 100644
index 00000000..90a84785
--- /dev/null
+++ b/database/migrations/2026_01_01_000027_create_theme_settings_table.php
@@ -0,0 +1,28 @@
+foreignId('theme_id')->primary()->constrained()->cascadeOnDelete();
+ $table->text('settings_json')->default('{}');
+ $table->timestamp('updated_at')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('theme_settings');
+ }
+};
diff --git a/database/migrations/2026_01_01_000028_create_search_queries_table.php b/database/migrations/2026_01_01_000028_create_search_queries_table.php
new file mode 100644
index 00000000..4b1e5b39
--- /dev/null
+++ b/database/migrations/2026_01_01_000028_create_search_queries_table.php
@@ -0,0 +1,35 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('query');
+ $table->text('filters_json')->nullable();
+ $table->integer('results_count')->default(0);
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('store_id', 'idx_search_queries_store_id');
+ $table->index(['store_id', 'created_at'], 'idx_search_queries_store_created');
+ $table->index(['store_id', 'query'], 'idx_search_queries_store_query');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('search_queries');
+ }
+};
diff --git a/database/migrations/2026_01_01_000029_create_shipping_rates_table.php b/database/migrations/2026_01_01_000029_create_shipping_rates_table.php
new file mode 100644
index 00000000..f8761039
--- /dev/null
+++ b/database/migrations/2026_01_01_000029_create_shipping_rates_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete();
+ $table->text('name');
+ $table->text('type')->default('flat');
+ $table->text('config_json')->default('{}');
+ $table->integer('is_active')->default(1);
+
+ $table->index('zone_id', 'idx_shipping_rates_zone_id');
+ $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('shipping_rates');
+ }
+};
diff --git a/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php b/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php
new file mode 100644
index 00000000..57e0c6e0
--- /dev/null
+++ b/database/migrations/2026_01_01_000030_create_oauth_tokens_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('installation_id')->constrained('app_installations')->cascadeOnDelete();
+ $table->text('access_token_hash');
+ $table->text('refresh_token_hash')->nullable();
+ $table->text('expires_at');
+
+ $table->index('installation_id', 'idx_oauth_tokens_installation_id');
+ $table->unique('access_token_hash', 'idx_oauth_tokens_access_hash');
+ $table->index('expires_at', 'idx_oauth_tokens_expires_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('oauth_tokens');
+ }
+};
diff --git a/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php b/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php
new file mode 100644
index 00000000..bd307b5f
--- /dev/null
+++ b/database/migrations/2026_01_01_000031_create_webhook_subscriptions_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('app_installation_id')->nullable()->constrained()->cascadeOnDelete();
+ $table->text('event_type');
+ $table->text('target_url');
+ $table->text('signing_secret_encrypted');
+ $table->text('status')->default('active');
+
+ $table->index('store_id', 'idx_webhook_subscriptions_store_id');
+ $table->index(['store_id', 'event_type'], 'idx_webhook_subscriptions_store_event');
+ $table->index('app_installation_id', 'idx_webhook_subscriptions_installation');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_subscriptions');
+ }
+};
diff --git a/database/migrations/2026_01_01_000032_create_product_option_values_table.php b/database/migrations/2026_01_01_000032_create_product_option_values_table.php
new file mode 100644
index 00000000..a67a71f6
--- /dev/null
+++ b/database/migrations/2026_01_01_000032_create_product_option_values_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('product_option_id')->constrained()->cascadeOnDelete();
+ $table->text('value');
+ $table->integer('position')->default(0);
+
+ $table->index('product_option_id', 'idx_product_option_values_option_id');
+ $table->unique(['product_option_id', 'position'], 'idx_product_option_values_option_position');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('product_option_values');
+ }
+};
diff --git a/database/migrations/2026_01_01_000033_create_inventory_items_table.php b/database/migrations/2026_01_01_000033_create_inventory_items_table.php
new file mode 100644
index 00000000..35146d90
--- /dev/null
+++ b/database/migrations/2026_01_01_000033_create_inventory_items_table.php
@@ -0,0 +1,34 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
+ $table->integer('quantity_on_hand')->default(0);
+ $table->integer('quantity_reserved')->default(0);
+ $table->text('policy')->default('deny');
+
+ $table->unique('variant_id', 'idx_inventory_items_variant_id');
+ $table->index('store_id', 'idx_inventory_items_store_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('inventory_items');
+ }
+};
diff --git a/database/migrations/2026_01_01_000034_create_cart_lines_table.php b/database/migrations/2026_01_01_000034_create_cart_lines_table.php
new file mode 100644
index 00000000..3adcdd3e
--- /dev/null
+++ b/database/migrations/2026_01_01_000034_create_cart_lines_table.php
@@ -0,0 +1,36 @@
+id();
+ $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
+ $table->integer('quantity')->default(1);
+ $table->integer('unit_price_amount')->default(0);
+ $table->integer('line_subtotal_amount')->default(0);
+ $table->integer('line_discount_amount')->default(0);
+ $table->integer('line_total_amount')->default(0);
+
+ $table->index('cart_id', 'idx_cart_lines_cart_id');
+ $table->unique(['cart_id', 'variant_id'], 'idx_cart_lines_cart_variant');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('cart_lines');
+ }
+};
diff --git a/database/migrations/2026_01_01_000035_create_checkouts_table.php b/database/migrations/2026_01_01_000035_create_checkouts_table.php
new file mode 100644
index 00000000..7724409c
--- /dev/null
+++ b/database/migrations/2026_01_01_000035_create_checkouts_table.php
@@ -0,0 +1,46 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('cart_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+ $table->text('status')->default('started');
+ $table->text('payment_method')->nullable();
+ $table->text('email')->nullable();
+ $table->text('shipping_address_json')->nullable();
+ $table->text('billing_address_json')->nullable();
+ $table->integer('shipping_method_id')->nullable();
+ $table->text('discount_code')->nullable();
+ $table->text('tax_provider_snapshot_json')->nullable();
+ $table->text('totals_json')->nullable();
+ $table->text('expires_at')->nullable();
+ $table->timestamps();
+
+ $table->index('store_id', 'idx_checkouts_store_id');
+ $table->index('cart_id', 'idx_checkouts_cart_id');
+ $table->index('customer_id', 'idx_checkouts_customer_id');
+ $table->index(['store_id', 'status'], 'idx_checkouts_status');
+ $table->index('expires_at', 'idx_checkouts_expires_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('checkouts');
+ }
+};
diff --git a/database/migrations/2026_01_01_000036_create_orders_table.php b/database/migrations/2026_01_01_000036_create_orders_table.php
new file mode 100644
index 00000000..d330e5f3
--- /dev/null
+++ b/database/migrations/2026_01_01_000036_create_orders_table.php
@@ -0,0 +1,52 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+ $table->text('order_number');
+ $table->text('payment_method');
+ $table->text('status')->default('pending');
+ $table->text('financial_status')->default('pending');
+ $table->text('fulfillment_status')->default('unfulfilled');
+ $table->text('currency')->default('USD');
+ $table->integer('subtotal_amount')->default(0);
+ $table->integer('discount_amount')->default(0);
+ $table->integer('shipping_amount')->default(0);
+ $table->integer('tax_amount')->default(0);
+ $table->integer('total_amount')->default(0);
+ $table->text('email')->nullable();
+ $table->text('billing_address_json')->nullable();
+ $table->text('shipping_address_json')->nullable();
+ $table->text('placed_at')->nullable();
+ $table->timestamps();
+
+ $table->unique(['store_id', 'order_number'], 'idx_orders_store_order_number');
+ $table->index('store_id', 'idx_orders_store_id');
+ $table->index('customer_id', 'idx_orders_customer_id');
+ $table->index(['store_id', 'status'], 'idx_orders_store_status');
+ $table->index(['store_id', 'financial_status'], 'idx_orders_store_financial');
+ $table->index(['store_id', 'fulfillment_status'], 'idx_orders_store_fulfillment');
+ $table->index(['store_id', 'placed_at'], 'idx_orders_placed_at');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('orders');
+ }
+};
diff --git a/database/migrations/2026_01_01_000037_create_analytics_events_table.php b/database/migrations/2026_01_01_000037_create_analytics_events_table.php
new file mode 100644
index 00000000..9c759bae
--- /dev/null
+++ b/database/migrations/2026_01_01_000037_create_analytics_events_table.php
@@ -0,0 +1,41 @@
+id();
+ $table->foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('type');
+ $table->text('session_id')->nullable();
+ $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete();
+ $table->text('properties_json')->default('{}');
+ $table->text('client_event_id')->nullable();
+ $table->text('occurred_at')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('store_id', 'idx_analytics_events_store_id');
+ $table->index(['store_id', 'type'], 'idx_analytics_events_store_type');
+ $table->index(['store_id', 'created_at'], 'idx_analytics_events_store_created');
+ $table->index('session_id', 'idx_analytics_events_session');
+ $table->index('customer_id', 'idx_analytics_events_customer');
+ $table->unique(['store_id', 'client_event_id'], 'idx_analytics_events_client_event');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('analytics_events');
+ }
+};
diff --git a/database/migrations/2026_01_01_000038_create_analytics_daily_table.php b/database/migrations/2026_01_01_000038_create_analytics_daily_table.php
new file mode 100644
index 00000000..480273f6
--- /dev/null
+++ b/database/migrations/2026_01_01_000038_create_analytics_daily_table.php
@@ -0,0 +1,37 @@
+foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('date');
+ $table->integer('orders_count')->default(0);
+ $table->integer('revenue_amount')->default(0);
+ $table->integer('aov_amount')->default(0);
+ $table->integer('visits_count')->default(0);
+ $table->integer('add_to_cart_count')->default(0);
+ $table->integer('checkout_started_count')->default(0);
+ $table->integer('checkout_completed_count')->default(0);
+
+ $table->primary(['store_id', 'date']);
+ $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('analytics_daily');
+ }
+};
diff --git a/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php b/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php
new file mode 100644
index 00000000..2d259cee
--- /dev/null
+++ b/database/migrations/2026_01_01_000039_create_webhook_deliveries_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete();
+ $table->text('event_id');
+ $table->integer('attempt_count')->default(1);
+ $table->text('status')->default('pending');
+ $table->text('last_attempt_at')->nullable();
+ $table->integer('response_code')->nullable();
+ $table->text('response_body_snippet')->nullable();
+
+ $table->index('subscription_id', 'idx_webhook_deliveries_subscription_id');
+ $table->index('event_id', 'idx_webhook_deliveries_event_id');
+ $table->index('status', 'idx_webhook_deliveries_status');
+ $table->index('last_attempt_at', 'idx_webhook_deliveries_last_attempt');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('webhook_deliveries');
+ }
+};
diff --git a/database/migrations/2026_01_01_000040_create_variant_option_values_table.php b/database/migrations/2026_01_01_000040_create_variant_option_values_table.php
new file mode 100644
index 00000000..9381e442
--- /dev/null
+++ b/database/migrations/2026_01_01_000040_create_variant_option_values_table.php
@@ -0,0 +1,30 @@
+foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete();
+ $table->foreignId('product_option_value_id')->constrained()->cascadeOnDelete();
+
+ $table->primary(['variant_id', 'product_option_value_id']);
+ $table->index('product_option_value_id', 'idx_variant_option_values_value_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('variant_option_values');
+ }
+};
diff --git a/database/migrations/2026_01_01_000041_create_order_lines_table.php b/database/migrations/2026_01_01_000041_create_order_lines_table.php
new file mode 100644
index 00000000..d087a852
--- /dev/null
+++ b/database/migrations/2026_01_01_000041_create_order_lines_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('order_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('product_id')->nullable()->constrained()->nullOnDelete();
+ $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete();
+ $table->text('title_snapshot');
+ $table->text('sku_snapshot')->nullable();
+ $table->integer('quantity')->default(1);
+ $table->integer('unit_price_amount')->default(0);
+ $table->integer('total_amount')->default(0);
+ $table->text('tax_lines_json')->default('[]');
+ $table->text('discount_allocations_json')->default('[]');
+
+ $table->index('order_id', 'idx_order_lines_order_id');
+ $table->index('product_id', 'idx_order_lines_product_id');
+ $table->index('variant_id', 'idx_order_lines_variant_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('order_lines');
+ }
+};
diff --git a/database/migrations/2026_01_01_000042_create_payments_table.php b/database/migrations/2026_01_01_000042_create_payments_table.php
new file mode 100644
index 00000000..53729d49
--- /dev/null
+++ b/database/migrations/2026_01_01_000042_create_payments_table.php
@@ -0,0 +1,40 @@
+id();
+ $table->foreignId('order_id')->constrained()->cascadeOnDelete();
+ $table->text('provider')->default('mock');
+ $table->text('method');
+ $table->text('provider_payment_id')->nullable();
+ $table->text('status')->default('pending');
+ $table->integer('amount')->default(0);
+ $table->text('currency')->default('USD');
+ $table->text('raw_json_encrypted')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('order_id', 'idx_payments_order_id');
+ $table->index(['provider', 'provider_payment_id'], 'idx_payments_provider_id');
+ $table->index('method', 'idx_payments_method');
+ $table->index('status', 'idx_payments_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('payments');
+ }
+};
diff --git a/database/migrations/2026_01_01_000043_create_fulfillments_table.php b/database/migrations/2026_01_01_000043_create_fulfillments_table.php
new file mode 100644
index 00000000..09f358f3
--- /dev/null
+++ b/database/migrations/2026_01_01_000043_create_fulfillments_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('order_id')->constrained()->cascadeOnDelete();
+ $table->text('status')->default('pending');
+ $table->text('tracking_company')->nullable();
+ $table->text('tracking_number')->nullable();
+ $table->text('tracking_url')->nullable();
+ $table->text('shipped_at')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('order_id', 'idx_fulfillments_order_id');
+ $table->index('status', 'idx_fulfillments_status');
+ $table->index(['tracking_company', 'tracking_number'], 'idx_fulfillments_tracking');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fulfillments');
+ }
+};
diff --git a/database/migrations/2026_01_01_000044_create_refunds_table.php b/database/migrations/2026_01_01_000044_create_refunds_table.php
new file mode 100644
index 00000000..1e307009
--- /dev/null
+++ b/database/migrations/2026_01_01_000044_create_refunds_table.php
@@ -0,0 +1,37 @@
+id();
+ $table->foreignId('order_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('payment_id')->constrained()->cascadeOnDelete();
+ $table->integer('amount')->default(0);
+ $table->text('reason')->nullable();
+ $table->text('status')->default('pending');
+ $table->text('provider_refund_id')->nullable();
+ $table->timestamp('created_at')->nullable();
+
+ $table->index('order_id', 'idx_refunds_order_id');
+ $table->index('payment_id', 'idx_refunds_payment_id');
+ $table->index('status', 'idx_refunds_status');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('refunds');
+ }
+};
diff --git a/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php b/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php
new file mode 100644
index 00000000..4f347f72
--- /dev/null
+++ b/database/migrations/2026_01_01_000045_create_fulfillment_lines_table.php
@@ -0,0 +1,32 @@
+id();
+ $table->foreignId('fulfillment_id')->constrained()->cascadeOnDelete();
+ $table->foreignId('order_line_id')->constrained()->cascadeOnDelete();
+ $table->integer('quantity')->default(1);
+
+ $table->index('fulfillment_id', 'idx_fulfillment_lines_fulfillment_id');
+ $table->unique(['fulfillment_id', 'order_line_id'], 'idx_fulfillment_lines_fulfillment_order_line');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('fulfillment_lines');
+ }
+};
diff --git a/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php b/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php
new file mode 100644
index 00000000..2bd819f6
--- /dev/null
+++ b/database/migrations/2026_01_01_000046_create_customer_password_reset_tokens_table.php
@@ -0,0 +1,31 @@
+foreignId('store_id')->constrained()->cascadeOnDelete();
+ $table->text('email');
+ $table->text('token');
+ $table->timestamp('created_at')->nullable();
+
+ $table->primary(['store_id', 'email']);
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('customer_password_reset_tokens');
+ }
+};
diff --git a/routes/admin.php b/routes/admin.php
new file mode 100644
index 00000000..1d2136f2
--- /dev/null
+++ b/routes/admin.php
@@ -0,0 +1,17 @@
+name('admin.')->group(function (): void {
+ // Authentication pages (Phase 6 replaces these placeholders with Livewire components).
+ Route::get('/login', fn (): string => 'admin login')->name('login');
+ Route::post('/login', fn (): string => 'admin login')->name('login.attempt');
+ Route::get('/forgot-password', fn (): string => 'admin forgot password')->name('password.request');
+ Route::post('/forgot-password', fn (): string => 'admin forgot password')->name('password.email');
+ Route::get('/reset-password/{token}', fn (string $token): string => 'admin reset password')->name('password.reset');
+
+ Route::middleware(['auth', 'verified', 'store.resolve:admin', 'role.check:owner,admin,staff,support'])->group(function (): void {
+ Route::get('/', fn (): string => 'admin ok')->name('dashboard');
+ });
+});
diff --git a/routes/api.php b/routes/api.php
new file mode 100644
index 00000000..347471b7
--- /dev/null
+++ b/routes/api.php
@@ -0,0 +1,17 @@
+prefix('storefront/v1')
+ ->group(function (): void {
+ //
+ });
+
+// Admin REST API (Sanctum personal access tokens). Endpoints are added in later phases.
+Route::middleware(['auth:sanctum', 'store.resolve:admin', 'throttle:api.admin'])
+ ->prefix('admin/v1')
+ ->group(function (): void {
+ //
+ });
diff --git a/routes/web.php b/routes/web.php
index f755f111..8405d81d 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,12 +2,7 @@
use Illuminate\Support\Facades\Route;
-Route::get('/', function () {
- return view('welcome');
-})->name('home');
-
-Route::view('dashboard', 'dashboard')
- ->middleware(['auth', 'verified'])
- ->name('dashboard');
-
-require __DIR__.'/settings.php';
+// Storefront routes (Phase 3 replaces the placeholder with real pages).
+Route::middleware(['store.resolve:storefront'])->group(function (): void {
+ Route::get('/', fn (): string => 'storefront ok');
+});
diff --git a/specs/progress.md b/specs/progress.md
index 8ec24534..89d16bfa 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -14,7 +14,8 @@
| # | Date | Phase | Summary | Commit |
|---|------|-------|---------|--------|
-| 0 | 2026-07-19 | Setup | Project scaffolding: sanctum + pest-plugin-browser deps, .env.testing, Herd site links (acme-fashion.test, acme-electronics.test), starter-kit cleanup | - |
+| 0 | 2026-07-19 | Setup | Project scaffolding: sanctum + pest-plugin-browser deps, .env.testing, Herd site links (acme-fashion.test, acme-electronics.test), starter-kit cleanup, Fortify removed | c002d312 |
+| 1 | 2026-07-19 | Phase 1 | Foundation: all 46-table migrations (+users rewrite), 28 enums, core models (Organization/Store/StoreDomain/StoreSettings/StoreUser/User/Customer), BelongsToStore+StoreScope, ResolveStore/CheckStoreRole/CustomerAuthenticate middleware, CustomerUserProvider, 11 policies, 9 gates, 7 rate limiters, route wiring (web/admin/api). 15 tests green. | pending |
---
diff --git a/tests/Feature/Tenancy/StoreIsolationTest.php b/tests/Feature/Tenancy/StoreIsolationTest.php
new file mode 100644
index 00000000..ce4d0bad
--- /dev/null
+++ b/tests/Feature/Tenancy/StoreIsolationTest.php
@@ -0,0 +1,45 @@
+createStore();
+ $storeB = $this->createStore();
+
+ Customer::factory()->count(2)->create(['store_id' => $storeA->id]);
+ Customer::factory()->count(3)->create(['store_id' => $storeB->id]);
+
+ $this->bindStore($storeA);
+
+ expect(Customer::query()->count())->toBe(2)
+ ->and(Customer::query()->pluck('store_id')->unique()->values()->all())->toBe([$storeA->id]);
+});
+
+test('queries are unscoped when no store is bound', function () {
+ $storeA = $this->createStore();
+ $storeB = $this->createStore();
+
+ Customer::factory()->count(2)->create(['store_id' => $storeA->id]);
+ Customer::factory()->create(['store_id' => $storeB->id]);
+
+ expect(Customer::query()->count())->toBe(3);
+});
+
+test('creating a tenant model sets store_id from the bound store', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $customer = Customer::factory()->create(['store_id' => null]);
+
+ expect($customer->store_id)->toBe($store->id);
+});
+
+test('an explicit store_id is not overridden by the bound store', function () {
+ $storeA = $this->createStore();
+ $storeB = $this->createStore();
+ $this->bindStore($storeA);
+
+ $customer = Customer::factory()->create(['store_id' => $storeB->id]);
+
+ expect($customer->store_id)->toBe($storeB->id);
+});
diff --git a/tests/Feature/Tenancy/TenantResolutionTest.php b/tests/Feature/Tenancy/TenantResolutionTest.php
new file mode 100644
index 00000000..410d94e1
--- /dev/null
+++ b/tests/Feature/Tenancy/TenantResolutionTest.php
@@ -0,0 +1,65 @@
+createStore(['handle' => 'acme-fashion']);
+
+ $response = $this->get('http://acme-fashion.test/');
+
+ $response->assertOk();
+ expect(app('current_store')->id)->toBe($store->id);
+});
+
+test('storefront returns 404 for an unknown hostname', function () {
+ $this->get('http://missing-shop.test/')->assertNotFound();
+});
+
+test('storefront returns 503 when the store is suspended', function () {
+ $this->createStore(['handle' => 'suspended-shop', 'status' => StoreStatus::Suspended]);
+
+ $this->get('http://suspended-shop.test/')->assertServiceUnavailable();
+});
+
+test('admin resolves the store from the session', function () {
+ $store = $this->createStore();
+ $user = $this->createUserWithRole($store, StoreUserRole::Owner);
+
+ $response = $this->actingAs($user)
+ ->withSession(['current_store_id' => $store->id])
+ ->get('/admin');
+
+ $response->assertOk();
+ expect(app('current_store')->id)->toBe($store->id);
+});
+
+test('admin returns 403 when the user has no store membership', function () {
+ $store = $this->createStore();
+ $user = User::factory()->create();
+
+ $this->actingAs($user)
+ ->withSession(['current_store_id' => $store->id])
+ ->get('/admin')
+ ->assertForbidden();
+});
+
+test('admin returns 403 when the store is suspended', function () {
+ $store = $this->createStore(['status' => StoreStatus::Suspended]);
+ $user = $this->createUserWithRole($store, StoreUserRole::Admin);
+
+ $this->actingAs($user)
+ ->withSession(['current_store_id' => $store->id])
+ ->get('/admin')
+ ->assertForbidden();
+});
+
+test('admin returns 403 when no store is selected in the session', function () {
+ $store = $this->createStore();
+ $user = $this->createUserWithRole($store, StoreUserRole::Owner);
+
+ $this->actingAs($user)
+ ->get('/admin')
+ ->assertForbidden();
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 60f04a45..40d096b5 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -12,7 +12,7 @@
*/
pest()->extend(Tests\TestCase::class)
- // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
+ ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
/*
diff --git a/tests/TestCase.php b/tests/TestCase.php
index fe1ffc2f..aadad839 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,9 +2,54 @@
namespace Tests;
+use App\Enums\StoreUserRole;
+use App\Models\Store;
+use App\Models\StoreDomain;
+use App\Models\StoreUser;
+use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
- //
+ /**
+ * Create an organization, a store, and its primary storefront domain.
+ *
+ * @param array $attributes
+ */
+ protected function createStore(array $attributes = []): Store
+ {
+ $store = Store::factory()->create($attributes);
+
+ StoreDomain::factory()->create([
+ 'store_id' => $store->id,
+ 'hostname' => $store->handle.'.test',
+ 'is_primary' => true,
+ ]);
+
+ return $store;
+ }
+
+ /**
+ * Bind a store as the current tenant in the container.
+ */
+ protected function bindStore(Store $store): void
+ {
+ app()->instance('current_store', $store);
+ }
+
+ /**
+ * Create a user holding the given role for the store.
+ */
+ protected function createUserWithRole(Store $store, string|StoreUserRole $role): User
+ {
+ $user = User::factory()->create();
+
+ StoreUser::create([
+ 'store_id' => $store->id,
+ 'user_id' => $user->id,
+ 'role' => $role instanceof StoreUserRole ? $role : StoreUserRole::from($role),
+ ]);
+
+ return $user;
+ }
}
diff --git a/tests/Unit/EnumsTest.php b/tests/Unit/EnumsTest.php
new file mode 100644
index 00000000..e03415fb
--- /dev/null
+++ b/tests/Unit/EnumsTest.php
@@ -0,0 +1,32 @@
+toHaveCount(4)
+ ->and(StoreUserRole::Owner->value)->toBe('owner')
+ ->and(StoreUserRole::Admin->value)->toBe('admin')
+ ->and(StoreUserRole::Staff->value)->toBe('staff')
+ ->and(StoreUserRole::Support->value)->toBe('support')
+ ->and(StoreUserRole::from('support'))->toBe(StoreUserRole::Support);
+});
+
+test('checkout status uses payment_selected', function () {
+ expect(CheckoutStatus::cases())->toHaveCount(6)
+ ->and(CheckoutStatus::PaymentSelected->value)->toBe('payment_selected')
+ ->and(CheckoutStatus::ShippingSelected->value)->toBe('shipping_selected');
+});
+
+test('discount value type values', function () {
+ expect(DiscountValueType::Percent->value)->toBe('percent')
+ ->and(DiscountValueType::Fixed->value)->toBe('fixed')
+ ->and(DiscountValueType::FreeShipping->value)->toBe('free_shipping');
+});
+
+test('financial status values', function () {
+ expect(FinancialStatus::cases())->toHaveCount(6)
+ ->and(FinancialStatus::PartiallyRefunded->value)->toBe('partially_refunded');
+});
From 3294c6ffeefe8459d347722b17d57bdd879c49a7 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 11:49:04 +0200
Subject: [PATCH 04/29] =?UTF-8?q?Phase=202:=20Catalog=20=E2=80=94=20produc?=
=?UTF-8?q?t/variant/inventory/collection/media=20models,=20services,=20jo?=
=?UTF-8?q?bs?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
app/Actions/SanitizeHtml.php | 224 +++++++++
app/Events/ProductCreated.php | 13 +
app/Events/ProductDeleted.php | 13 +
app/Events/ProductStatusChanged.php | 18 +
app/Events/ProductUpdated.php | 13 +
.../InsufficientInventoryException.php | 19 +
.../InvalidProductTransitionException.php | 30 ++
app/Jobs/ProcessMediaUpload.php | 162 +++++++
app/Models/Collection.php | 55 +++
app/Models/InventoryItem.php | 65 +++
app/Models/Product.php | 123 +++++
app/Models/ProductMedia.php | 115 +++++
app/Models/ProductOption.php | 52 +++
app/Models/ProductOptionValue.php | 41 ++
app/Models/ProductVariant.php | 116 +++++
app/Services/InventoryService.php | 83 ++++
app/Services/ProductService.php | 434 ++++++++++++++++++
app/Services/VariantMatrixService.php | 144 ++++++
app/Support/HandleGenerator.php | 46 ++
database/factories/CollectionFactory.php | 51 ++
database/factories/InventoryItemFactory.php | 40 ++
database/factories/ProductFactory.php | 88 ++++
database/factories/ProductMediaFactory.php | 41 ++
database/factories/ProductOptionFactory.php | 26 ++
.../factories/ProductOptionValueFactory.php | 26 ++
database/factories/ProductVariantFactory.php | 68 +++
specs/progress.md | 34 +-
tests/Feature/Products/CollectionTest.php | 79 ++++
tests/Feature/Products/InventoryTest.php | 130 ++++++
tests/Feature/Products/MediaUploadTest.php | 150 ++++++
tests/Feature/Products/ProductCrudTest.php | 275 +++++++++++
tests/Feature/Products/VariantTest.php | 198 ++++++++
tests/Pest.php | 24 +
tests/Unit/HandleGeneratorTest.php | 49 ++
tests/Unit/SanitizeHtmlTest.php | 103 +++++
35 files changed, 3132 insertions(+), 16 deletions(-)
create mode 100644 app/Actions/SanitizeHtml.php
create mode 100644 app/Events/ProductCreated.php
create mode 100644 app/Events/ProductDeleted.php
create mode 100644 app/Events/ProductStatusChanged.php
create mode 100644 app/Events/ProductUpdated.php
create mode 100644 app/Exceptions/InsufficientInventoryException.php
create mode 100644 app/Exceptions/InvalidProductTransitionException.php
create mode 100644 app/Jobs/ProcessMediaUpload.php
create mode 100644 app/Models/Collection.php
create mode 100644 app/Models/InventoryItem.php
create mode 100644 app/Models/Product.php
create mode 100644 app/Models/ProductMedia.php
create mode 100644 app/Models/ProductOption.php
create mode 100644 app/Models/ProductOptionValue.php
create mode 100644 app/Models/ProductVariant.php
create mode 100644 app/Services/InventoryService.php
create mode 100644 app/Services/ProductService.php
create mode 100644 app/Services/VariantMatrixService.php
create mode 100644 app/Support/HandleGenerator.php
create mode 100644 database/factories/CollectionFactory.php
create mode 100644 database/factories/InventoryItemFactory.php
create mode 100644 database/factories/ProductFactory.php
create mode 100644 database/factories/ProductMediaFactory.php
create mode 100644 database/factories/ProductOptionFactory.php
create mode 100644 database/factories/ProductOptionValueFactory.php
create mode 100644 database/factories/ProductVariantFactory.php
create mode 100644 tests/Feature/Products/CollectionTest.php
create mode 100644 tests/Feature/Products/InventoryTest.php
create mode 100644 tests/Feature/Products/MediaUploadTest.php
create mode 100644 tests/Feature/Products/ProductCrudTest.php
create mode 100644 tests/Feature/Products/VariantTest.php
create mode 100644 tests/Unit/HandleGeneratorTest.php
create mode 100644 tests/Unit/SanitizeHtmlTest.php
diff --git a/app/Actions/SanitizeHtml.php b/app/Actions/SanitizeHtml.php
new file mode 100644
index 00000000..da539084
--- /dev/null
+++ b/app/Actions/SanitizeHtml.php
@@ -0,0 +1,224 @@
+>
+ */
+ private const ALLOWED = [
+ 'p' => [],
+ 'br' => [],
+ 'strong' => [],
+ 'em' => [],
+ 'u' => [],
+ 'ol' => [],
+ 'ul' => [],
+ 'li' => [],
+ 'a' => ['href'],
+ 'img' => ['src', 'alt'],
+ 'h1' => [],
+ 'h2' => [],
+ 'h3' => [],
+ 'h4' => [],
+ 'h5' => [],
+ 'h6' => [],
+ 'blockquote' => [],
+ 'table' => [],
+ 'thead' => [],
+ 'tbody' => [],
+ 'tr' => [],
+ 'th' => [],
+ 'td' => [],
+ 'div' => [],
+ 'span' => [],
+ ];
+
+ /**
+ * Elements removed together with their text content.
+ *
+ * @var list
+ */
+ private const REMOVE_WITH_CONTENT = ['script', 'style'];
+
+ /**
+ * Allowed URL schemes for href/src attributes (relative URLs pass too).
+ *
+ * @var list
+ */
+ private const ALLOWED_SCHEMES = ['http', 'https', 'mailto'];
+
+ /**
+ * Elements pruned when they contain no text and no element children.
+ *
+ * @var list
+ */
+ private const PRUNE_WHEN_EMPTY = [
+ 'p', 'strong', 'em', 'u', 'ol', 'ul', 'li', 'a',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote',
+ 'table', 'thead', 'tbody', 'tr', 'div', 'span',
+ ];
+
+ /**
+ * Sanitize the given HTML fragment. Null and empty input pass through.
+ */
+ public function __invoke(?string $html): ?string
+ {
+ if ($html === null || trim($html) === '') {
+ return $html;
+ }
+
+ $document = new DOMDocument;
+ $document->loadHTML(
+ ''.$html,
+ LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING
+ );
+
+ // Remove the encoding workaround processing instruction.
+ foreach (iterator_to_array($document->childNodes) as $child) {
+ if ($child instanceof \DOMProcessingInstruction) {
+ $document->removeChild($child);
+ }
+ }
+
+ $this->sanitizeChildren($document);
+ $this->pruneEmptyElements($document);
+
+ $output = '';
+ foreach ($document->childNodes as $child) {
+ $output .= $document->saveHTML($child);
+ }
+
+ return trim($output);
+ }
+
+ /**
+ * Recursively sanitize all child nodes of the given parent.
+ */
+ private function sanitizeChildren(DOMNode $parent): void
+ {
+ foreach (iterator_to_array($parent->childNodes) as $child) {
+ if (! $child instanceof DOMElement) {
+ continue;
+ }
+
+ $tag = strtolower($child->tagName);
+
+ if (in_array($tag, self::REMOVE_WITH_CONTENT, true)) {
+ $parent->removeChild($child);
+
+ continue;
+ }
+
+ if (! array_key_exists($tag, self::ALLOWED)) {
+ // Unwrap: keep the children, drop the element itself.
+ $this->sanitizeChildren($child);
+
+ while ($child->firstChild !== null) {
+ $parent->insertBefore($child->firstChild, $child);
+ }
+ $parent->removeChild($child);
+
+ continue;
+ }
+
+ $this->sanitizeAttributes($child, $tag);
+ $this->sanitizeChildren($child);
+ }
+ }
+
+ /**
+ * Strip attributes outside the allowlist and neutralize unsafe URLs.
+ *
+ * @param key-of $tag
+ */
+ private function sanitizeAttributes(DOMElement $element, string $tag): void
+ {
+ $allowedAttributes = self::ALLOWED[$tag];
+
+ foreach (iterator_to_array($element->attributes) as $attribute) {
+ $name = strtolower($attribute->nodeName);
+
+ if (! in_array($name, $allowedAttributes, true)) {
+ $element->removeAttribute($attribute->nodeName);
+
+ continue;
+ }
+
+ if (in_array($name, ['href', 'src'], true) && ! $this->isSafeUrl($attribute->nodeValue)) {
+ $element->removeAttribute($attribute->nodeName);
+ }
+ }
+ }
+
+ /**
+ * Allow relative URLs and safe schemes only (blocks javascript:, data:, ...).
+ */
+ private function isSafeUrl(string $url): bool
+ {
+ $url = trim($url);
+
+ if ($url === '' || str_starts_with($url, '#')) {
+ return true;
+ }
+
+ $scheme = parse_url($url, PHP_URL_SCHEME);
+
+ if ($scheme === null) {
+ return true; // relative URL
+ }
+
+ return in_array(strtolower($scheme), self::ALLOWED_SCHEMES, true);
+ }
+
+ /**
+ * Whether the element has at least one child element.
+ */
+ private function hasElementChild(DOMElement $element): bool
+ {
+ foreach ($element->childNodes as $child) {
+ if ($child instanceof DOMElement) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Remove elements that carry no content (repeatedly, for nesting).
+ */
+ private function pruneEmptyElements(DOMDocument $document): void
+ {
+ $xpath = new DOMXPath($document);
+
+ do {
+ $removed = 0;
+
+ foreach (self::PRUNE_WHEN_EMPTY as $tag) {
+ foreach (iterator_to_array($xpath->query('//'.$tag) ?: []) as $element) {
+ /** @var DOMElement $element */
+ if (trim($element->textContent) === '' && ! $this->hasElementChild($element)) {
+ $element->parentNode?->removeChild($element);
+ $removed++;
+ }
+ }
+ }
+ } while ($removed > 0);
+ }
+}
diff --git a/app/Events/ProductCreated.php b/app/Events/ProductCreated.php
new file mode 100644
index 00000000..7d54f112
--- /dev/null
+++ b/app/Events/ProductCreated.php
@@ -0,0 +1,13 @@
+variant_id}: requested {$quantity}, available {$item->available()}."
+ );
+ }
+}
diff --git a/app/Exceptions/InvalidProductTransitionException.php b/app/Exceptions/InvalidProductTransitionException.php
new file mode 100644
index 00000000..2be65691
--- /dev/null
+++ b/app/Exceptions/InvalidProductTransitionException.php
@@ -0,0 +1,30 @@
+ maximum width/height in pixels.
+ *
+ * @var array
+ */
+ private const TARGETS = [
+ 'thumbnail' => 150,
+ 'small' => 300,
+ 'medium' => 600,
+ 'large' => 1200,
+ ];
+
+ public function __construct(public ProductMedia $media) {}
+
+ /**
+ * Resize the original into the standard sizes and mark the record ready.
+ *
+ * @throws RuntimeException when the original cannot be processed
+ */
+ public function handle(): void
+ {
+ if (! extension_loaded('gd')) {
+ throw new RuntimeException('The GD extension is required to process media uploads.');
+ }
+
+ $disk = Storage::disk('public');
+ $originalPath = $disk->path($this->media->storage_key);
+
+ $info = @getimagesize($originalPath);
+
+ if ($info === false) {
+ throw new RuntimeException("Cannot read image data for media {$this->media->id}.");
+ }
+
+ [$width, $height] = $info;
+ $mimeType = $info['mime'];
+
+ $source = $this->createImageFrom($originalPath, $mimeType);
+
+ foreach (self::TARGETS as $size => $maxDimension) {
+ [$targetWidth, $targetHeight] = $this->containDimensions($width, $height, $maxDimension);
+
+ $resized = imagecreatetruecolor($targetWidth, $targetHeight);
+
+ if ($resized === false) {
+ throw new RuntimeException("Failed to allocate canvas for media {$this->media->id}.");
+ }
+
+ imagecopyresampled($resized, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
+
+ $relativePath = $this->media->pathFor($size);
+ $absolutePath = $disk->path($relativePath);
+
+ if (! is_dir(dirname($absolutePath))) {
+ mkdir(dirname($absolutePath), 0755, true);
+ }
+
+ $this->saveImage($resized, $absolutePath, $mimeType);
+ imagedestroy($resized);
+ }
+
+ imagedestroy($source);
+
+ $this->media->update([
+ 'width' => $width,
+ 'height' => $height,
+ 'mime_type' => $mimeType,
+ 'byte_size' => $disk->size($this->media->storage_key),
+ 'status' => MediaStatus::Ready,
+ ]);
+ }
+
+ /**
+ * Mark the media as failed after the job exhausted its attempts.
+ */
+ public function failed(?Throwable $exception): void
+ {
+ Log::error('Media processing failed', [
+ 'product_media_id' => $this->media->id,
+ 'storage_key' => $this->media->storage_key,
+ 'error' => $exception?->getMessage(),
+ ]);
+
+ $this->media->update(['status' => MediaStatus::Failed]);
+ }
+
+ /**
+ * Load an image resource from disk based on its mime type.
+ */
+ private function createImageFrom(string $path, string $mimeType): \GdImage
+ {
+ $image = match ($mimeType) {
+ 'image/jpeg' => @imagecreatefromjpeg($path),
+ 'image/png' => @imagecreatefrompng($path),
+ 'image/webp' => @imagecreatefromwebp($path),
+ 'image/gif' => @imagecreatefromgif($path),
+ default => false,
+ };
+
+ if ($image === false) {
+ throw new RuntimeException("Unsupported or corrupt image ({$mimeType}) for media {$this->media->id}.");
+ }
+
+ return $image;
+ }
+
+ /**
+ * Compute dimensions contained within the max size, preserving aspect
+ * ratio and never upscaling.
+ *
+ * @return array{0: int, 1: int}
+ */
+ private function containDimensions(int $width, int $height, int $maxDimension): array
+ {
+ $ratio = min($maxDimension / $width, $maxDimension / $height, 1.0);
+
+ return [
+ max(1, (int) round($width * $ratio)),
+ max(1, (int) round($height * $ratio)),
+ ];
+ }
+
+ /**
+ * Persist an image resource in the given format.
+ */
+ private function saveImage(\GdImage $image, string $path, string $mimeType): void
+ {
+ $saved = match ($mimeType) {
+ 'image/jpeg' => imagejpeg($image, $path, 85),
+ 'image/png' => imagepng($image, $path),
+ 'image/webp' => imagewebp($image, $path),
+ 'image/gif' => imagegif($image, $path),
+ default => false,
+ };
+
+ if (! $saved) {
+ throw new RuntimeException("Failed to write resized image to {$path}.");
+ }
+ }
+}
diff --git a/app/Models/Collection.php b/app/Models/Collection.php
new file mode 100644
index 00000000..4f14797a
--- /dev/null
+++ b/app/Models/Collection.php
@@ -0,0 +1,55 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'title',
+ 'handle',
+ 'description_html',
+ 'type',
+ 'status',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => CollectionType::class,
+ 'status' => CollectionStatus::class,
+ ];
+ }
+
+ /**
+ * Get the products in the collection, ordered by position.
+ *
+ * @return BelongsToMany
+ */
+ public function products(): BelongsToMany
+ {
+ return $this->belongsToMany(Product::class, 'collection_products')
+ ->withPivot('position')
+ ->orderBy('collection_products.position');
+ }
+}
diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php
new file mode 100644
index 00000000..c96de366
--- /dev/null
+++ b/app/Models/InventoryItem.php
@@ -0,0 +1,65 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'variant_id',
+ 'quantity_on_hand',
+ 'quantity_reserved',
+ 'policy',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'policy' => InventoryPolicy::class,
+ ];
+ }
+
+ /**
+ * Get the variant tracked by this inventory item.
+ *
+ * @return BelongsTo
+ */
+ public function variant(): BelongsTo
+ {
+ return $this->belongsTo(ProductVariant::class, 'variant_id');
+ }
+
+ /**
+ * Available stock: on hand minus reserved.
+ */
+ public function available(): int
+ {
+ return $this->quantity_on_hand - $this->quantity_reserved;
+ }
+}
diff --git a/app/Models/Product.php b/app/Models/Product.php
new file mode 100644
index 00000000..04941e96
--- /dev/null
+++ b/app/Models/Product.php
@@ -0,0 +1,123 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'title',
+ 'handle',
+ 'status',
+ 'description_html',
+ 'vendor',
+ 'product_type',
+ 'tags',
+ 'published_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => ProductStatus::class,
+ 'tags' => 'array',
+ 'published_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Delete media records through Eloquent so the file-cleanup hook on
+ // ProductMedia fires (database cascade would bypass model events).
+ static::deleting(function (Product $product): void {
+ $product->media->each->delete();
+ });
+ }
+
+ /**
+ * Get the variants of the product.
+ *
+ * @return HasMany
+ */
+ public function variants(): HasMany
+ {
+ return $this->hasMany(ProductVariant::class)->orderBy('position');
+ }
+
+ /**
+ * Get the options of the product.
+ *
+ * @return HasMany
+ */
+ public function options(): HasMany
+ {
+ return $this->hasMany(ProductOption::class)->orderBy('position');
+ }
+
+ /**
+ * Get the media attached to the product.
+ *
+ * @return HasMany
+ */
+ public function media(): HasMany
+ {
+ return $this->hasMany(ProductMedia::class)->orderBy('position');
+ }
+
+ /**
+ * Get the collections containing the product.
+ *
+ * @return BelongsToMany
+ */
+ public function collections(): BelongsToMany
+ {
+ return $this->belongsToMany(Collection::class, 'collection_products')
+ ->withPivot('position')
+ ->orderBy('collection_products.position');
+ }
+
+ /**
+ * Get the default variant of the product.
+ *
+ * @return HasOne
+ */
+ public function defaultVariant(): HasOne
+ {
+ return $this->hasOne(ProductVariant::class)->where('is_default', true);
+ }
+
+ /**
+ * Scope to products visible on the storefront: active and published.
+ *
+ * @param Builder $query
+ */
+ protected function scopeVisible(Builder $query): void
+ {
+ $query->where('status', ProductStatus::Active)->whereNotNull('published_at');
+ }
+}
diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php
new file mode 100644
index 00000000..ecf410e8
--- /dev/null
+++ b/app/Models/ProductMedia.php
@@ -0,0 +1,115 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table only has a created_at column.
+ *
+ * @var string|null
+ */
+ const UPDATED_AT = null;
+
+ /**
+ * Processed derivative sizes generated by ProcessMediaUpload.
+ *
+ * @var list
+ */
+ const SIZES = ['thumbnail', 'small', 'medium', 'large'];
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'product_id',
+ 'type',
+ 'storage_key',
+ 'alt_text',
+ 'width',
+ 'height',
+ 'mime_type',
+ 'byte_size',
+ 'position',
+ 'status',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => MediaType::class,
+ 'status' => MediaStatus::class,
+ 'created_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Remove all files (original plus every processed size) from storage.
+ static::deleted(function (ProductMedia $media): void {
+ $disk = Storage::disk('public');
+
+ $disk->delete($media->storage_key);
+
+ foreach (self::SIZES as $size) {
+ $disk->delete($media->pathFor($size));
+ }
+ });
+ }
+
+ /**
+ * Get the product that owns the media.
+ *
+ * @return BelongsTo
+ */
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ /**
+ * Storage path of a processed derivative: media/{product_id}/{media_id}/{size}.{ext}
+ */
+ public function pathFor(string $size): string
+ {
+ $extension = pathinfo($this->storage_key, PATHINFO_EXTENSION);
+
+ return "media/{$this->product_id}/{$this->id}/{$size}.{$extension}";
+ }
+
+ /**
+ * Public URL of the original file.
+ */
+ public function url(): string
+ {
+ return Storage::disk('public')->url($this->storage_key);
+ }
+
+ /**
+ * Public URL of a processed derivative size.
+ */
+ public function urlFor(string $size): string
+ {
+ return Storage::disk('public')->url($this->pathFor($size));
+ }
+}
diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php
new file mode 100644
index 00000000..981ff959
--- /dev/null
+++ b/app/Models/ProductOption.php
@@ -0,0 +1,52 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'product_id',
+ 'name',
+ 'position',
+ ];
+
+ /**
+ * Get the product that owns the option.
+ *
+ * @return BelongsTo
+ */
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ /**
+ * Get the values of the option.
+ *
+ * @return HasMany
+ */
+ public function values(): HasMany
+ {
+ return $this->hasMany(ProductOptionValue::class)->orderBy('position');
+ }
+}
diff --git a/app/Models/ProductOptionValue.php b/app/Models/ProductOptionValue.php
new file mode 100644
index 00000000..40f4124f
--- /dev/null
+++ b/app/Models/ProductOptionValue.php
@@ -0,0 +1,41 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'product_option_id',
+ 'value',
+ 'position',
+ ];
+
+ /**
+ * Get the option that owns the value.
+ *
+ * @return BelongsTo
+ */
+ public function option(): BelongsTo
+ {
+ return $this->belongsTo(ProductOption::class, 'product_option_id');
+ }
+}
diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php
new file mode 100644
index 00000000..04110b89
--- /dev/null
+++ b/app/Models/ProductVariant.php
@@ -0,0 +1,116 @@
+ */
+ use HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'product_id',
+ 'sku',
+ 'barcode',
+ 'price_amount',
+ 'compare_at_amount',
+ 'currency',
+ 'weight_g',
+ 'requires_shipping',
+ 'is_default',
+ 'position',
+ 'status',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'requires_shipping' => 'boolean',
+ 'is_default' => 'boolean',
+ 'status' => VariantStatus::class,
+ ];
+ }
+
+ /**
+ * Get the product that owns the variant.
+ *
+ * @return BelongsTo
+ */
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ /**
+ * Get the inventory item tracking stock for the variant.
+ *
+ * @return HasOne
+ */
+ public function inventoryItem(): HasOne
+ {
+ return $this->hasOne(InventoryItem::class, 'variant_id');
+ }
+
+ /**
+ * Get the option values that define the variant.
+ *
+ * @return BelongsToMany
+ */
+ public function optionValues(): BelongsToMany
+ {
+ return $this->belongsToMany(ProductOptionValue::class, 'variant_option_values', 'variant_id', 'product_option_value_id');
+ }
+
+ /**
+ * Build the display title from the option values, e.g. "Blue / Medium".
+ */
+ public function title(): string
+ {
+ $values = $this->optionValues
+ ->sortBy(fn (ProductOptionValue $value) => $value->option->position)
+ ->pluck('value');
+
+ return $values->isEmpty() ? 'Default' : $values->implode(' / ');
+ }
+
+ /**
+ * Available stock: on hand minus reserved.
+ */
+ public function availableQuantity(): int
+ {
+ return $this->inventoryItem?->available() ?? 0;
+ }
+
+ /**
+ * Whether the variant has stock available to sell.
+ */
+ public function isInStock(): bool
+ {
+ return $this->availableQuantity() > 0;
+ }
+
+ /**
+ * Whether the variant may be sold below zero stock (backorder policy).
+ */
+ public function isBackorderable(): bool
+ {
+ return $this->inventoryItem?->policy === InventoryPolicy::Continue;
+ }
+}
diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php
new file mode 100644
index 00000000..a658deb1
--- /dev/null
+++ b/app/Services/InventoryService.php
@@ -0,0 +1,83 @@
+policy === InventoryPolicy::Continue) {
+ return true;
+ }
+
+ return $item->available() >= $quantity;
+ }
+
+ /**
+ * Reserve stock for an open checkout: quantity_reserved += quantity.
+ *
+ * @throws InsufficientInventoryException when policy is "deny" and
+ * available stock is insufficient
+ */
+ public function reserve(InventoryItem $item, int $quantity): void
+ {
+ DB::transaction(function () use ($item, $quantity): void {
+ $item = $this->lockAndRefresh($item);
+
+ if ($item->policy === InventoryPolicy::Deny && $item->available() < $quantity) {
+ throw InsufficientInventoryException::forReservation($item, $quantity);
+ }
+
+ $item->increment('quantity_reserved', $quantity);
+ });
+ }
+
+ /**
+ * Release a reservation (checkout expired/abandoned): reserved -= quantity.
+ */
+ public function release(InventoryItem $item, int $quantity): void
+ {
+ DB::transaction(function () use ($item, $quantity): void {
+ $this->lockAndRefresh($item)->decrement('quantity_reserved', $quantity);
+ });
+ }
+
+ /**
+ * Commit a reservation after payment: both on_hand and reserved decrease.
+ */
+ public function commit(InventoryItem $item, int $quantity): void
+ {
+ DB::transaction(function () use ($item, $quantity): void {
+ $locked = $this->lockAndRefresh($item);
+ $locked->decrement('quantity_on_hand', $quantity);
+ $locked->decrement('quantity_reserved', $quantity);
+ });
+ }
+
+ /**
+ * Restock returned units after a refund: on_hand += quantity.
+ */
+ public function restock(InventoryItem $item, int $quantity): void
+ {
+ DB::transaction(function () use ($item, $quantity): void {
+ $this->lockAndRefresh($item)->increment('quantity_on_hand', $quantity);
+ });
+ }
+
+ /**
+ * Reload the item with a write lock inside the current transaction.
+ */
+ private function lockAndRefresh(InventoryItem $item): InventoryItem
+ {
+ return InventoryItem::query()->lockForUpdate()->findOrFail($item->id);
+ }
+}
diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php
new file mode 100644
index 00000000..37c6a7e7
--- /dev/null
+++ b/app/Services/ProductService.php
@@ -0,0 +1,434 @@
+ $data
+ */
+ public function create(Store $store, array $data): Product
+ {
+ return DB::transaction(function () use ($store, $data): Product {
+ $product = new Product;
+ $product->store_id = $store->id;
+ $product->fill($this->productAttributes($data, $store->id));
+ $product->status = $data['status'] ?? ProductStatus::Draft;
+ $product->handle = HandleGenerator::generate(
+ $data['handle'] ?? $data['title'],
+ 'products',
+ $store->id,
+ );
+ $product->save();
+
+ $this->syncOptions($product, $data['options'] ?? []);
+
+ if ($product->options()->exists()) {
+ $this->variantMatrix->rebuildMatrix($product);
+ $this->applyVariantDefaults($product, $data['variant_defaults'] ?? []);
+ $this->applyVariantOverrides($product, $data['variants'] ?? []);
+ } else {
+ $this->createDefaultVariant($product, $data['variants'][0] ?? []);
+ }
+
+ ProductCreated::dispatch($product);
+
+ return $product->refresh();
+ });
+ }
+
+ /**
+ * Update a product and its nested structure.
+ *
+ * @param array $data
+ */
+ public function update(Product $product, array $data): Product
+ {
+ return DB::transaction(function () use ($product, $data): Product {
+ $product->fill($this->productAttributes($data, $product->store_id));
+
+ if (array_key_exists('handle', $data)) {
+ $product->handle = HandleGenerator::generate(
+ $data['handle'] ?: $product->title,
+ 'products',
+ $product->store_id,
+ $product->id,
+ );
+ }
+
+ $product->save();
+
+ if (array_key_exists('options', $data)) {
+ $this->syncOptions($product, $data['options']);
+
+ if ($product->options()->exists()) {
+ $this->variantMatrix->rebuildMatrix($product);
+ $this->applyVariantDefaults($product, $data['variant_defaults'] ?? []);
+ $this->applyVariantOverrides($product, $data['variants'] ?? []);
+ } elseif (! $product->variants()->exists()) {
+ $this->createDefaultVariant($product, $data['variants'][0] ?? []);
+ }
+ } elseif (array_key_exists('variants', $data)) {
+ $this->applyVariantOverrides($product, $data['variants']);
+ }
+
+ ProductUpdated::dispatch($product);
+
+ return $product->refresh();
+ });
+ }
+
+ /**
+ * Transition the product to a new status, enforcing the state machine.
+ *
+ * @throws InvalidProductTransitionException
+ */
+ public function transitionStatus(Product $product, ProductStatus $newStatus): void
+ {
+ DB::transaction(function () use ($product, $newStatus): void {
+ $oldStatus = $product->status;
+
+ if ($oldStatus === $newStatus) {
+ throw InvalidProductTransitionException::transition($oldStatus->value, $newStatus->value, 'Product is already in this status.');
+ }
+
+ match ($newStatus) {
+ ProductStatus::Active => $this->assertPublishable($product, $oldStatus),
+ ProductStatus::Draft => $this->assertNoOrderReferences($product, $oldStatus, $newStatus),
+ ProductStatus::Archived => null,
+ };
+
+ $product->status = $newStatus;
+
+ if ($newStatus === ProductStatus::Active && $product->published_at === null) {
+ $product->published_at = now();
+ }
+
+ $product->save();
+
+ ProductStatusChanged::dispatch($product, $oldStatus, $newStatus);
+ });
+ }
+
+ /**
+ * Hard-delete a product. Only drafts without order references may be
+ * deleted; everything else must be archived instead.
+ *
+ * @throws InvalidProductTransitionException
+ */
+ public function delete(Product $product): void
+ {
+ DB::transaction(function () use ($product): void {
+ if ($product->status !== ProductStatus::Draft) {
+ throw InvalidProductTransitionException::deletion('Only draft products can be deleted; archive it instead.');
+ }
+
+ if ($this->hasOrderReferences($product)) {
+ throw InvalidProductTransitionException::deletion('Products referenced by orders cannot be deleted; archive it instead.');
+ }
+
+ $product->delete();
+
+ ProductDeleted::dispatch($product);
+ });
+ }
+
+ /**
+ * Extract and sanitize the product's own attributes from the payload.
+ *
+ * @return array
+ */
+ private function productAttributes(array $data, int $storeId): array
+ {
+ $attributes = Arr::only($data, [
+ 'title', 'description_html', 'vendor', 'product_type', 'tags', 'published_at',
+ ]);
+
+ if (array_key_exists('description_html', $attributes)) {
+ $attributes['description_html'] = ($this->sanitizeHtml)($attributes['description_html']);
+ }
+
+ return $attributes;
+ }
+
+ /**
+ * Sync the product's options and values against the payload.
+ *
+ * Existing records are matched by id, then by name/value, and updated in
+ * place so variant option-value links survive. Anything missing from the
+ * payload is deleted.
+ *
+ * @param list> $optionsData
+ */
+ private function syncOptions(Product $product, array $optionsData): void
+ {
+ $keepOptionIds = [];
+
+ foreach (array_values($optionsData) as $optionIndex => $optionData) {
+ $option = $this->matchOption($product, $optionData);
+ $option->fill(['name' => $optionData['name'], 'position' => $optionIndex + 1000])->save();
+
+ $keepValueIds = [];
+
+ foreach (array_values($optionData['values'] ?? []) as $valueIndex => $valueData) {
+ $valueData = is_array($valueData) ? $valueData : ['value' => $valueData];
+
+ $value = $this->matchOptionValue($option, $valueData);
+ $value->fill(['value' => $valueData['value'], 'position' => $valueIndex + 1000])->save();
+
+ $keepValueIds[] = $value->id;
+ }
+
+ $option->values()->whereNotIn('id', $keepValueIds)->delete();
+ $option->values()->whereIn('id', $keepValueIds)->get()->each(
+ fn ($value, $index) => $value->update(['position' => $index])
+ );
+
+ $option->update(['position' => $optionIndex]);
+ $keepOptionIds[] = $option->id;
+ }
+
+ $product->options()->whereNotIn('id', $keepOptionIds)->delete();
+ }
+
+ /**
+ * Find an existing option by id or name, or make a new instance.
+ *
+ * @param array $optionData
+ */
+ private function matchOption(Product $product, array $optionData): ProductOption
+ {
+ $option = null;
+
+ if (! empty($optionData['id'])) {
+ $option = $product->options()->whereKey($optionData['id'])->first();
+ }
+
+ $option ??= $product->options()->where('name', $optionData['name'])->first();
+
+ return $option ?? $product->options()->make();
+ }
+
+ /**
+ * Find an existing option value by id or value string, or make a new one.
+ *
+ * @param array $valueData
+ */
+ private function matchOptionValue(ProductOption $option, array $valueData): \App\Models\ProductOptionValue
+ {
+ $value = null;
+
+ if (! empty($valueData['id'])) {
+ $value = $option->values()->whereKey($valueData['id'])->first();
+ }
+
+ $value ??= $option->values()->where('value', $valueData['value'])->first();
+
+ return $value ?? $option->values()->make();
+ }
+
+ /**
+ * Create the single default variant for a product without options.
+ *
+ * @param array $variantData
+ */
+ private function createDefaultVariant(Product $product, array $variantData): void
+ {
+ $sku = $variantData['sku'] ?? null;
+ $this->assertSkuIsUnique($product->store_id, $sku);
+
+ $variant = $product->variants()->create(array_merge(
+ $this->variantAttributes($variantData),
+ ['is_default' => true, 'position' => 0],
+ ));
+
+ $variant->inventoryItem()->create(array_merge(
+ ['store_id' => $product->store_id, 'quantity_on_hand' => 0],
+ $variantData['inventory'] ?? [],
+ ));
+ }
+
+ /**
+ * Apply pricing defaults to all variants of the product.
+ *
+ * @param array $defaults
+ */
+ private function applyVariantDefaults(Product $product, array $defaults): void
+ {
+ if ($defaults === []) {
+ return;
+ }
+
+ foreach ($product->variants()->get() as $variant) {
+ $variant->update($this->variantAttributes($defaults));
+ }
+ }
+
+ /**
+ * Apply per-variant overrides, matched by id or by option value names.
+ *
+ * @param list> $variantsData
+ */
+ private function applyVariantOverrides(Product $product, array $variantsData): void
+ {
+ foreach ($variantsData as $variantData) {
+ $variant = $this->matchVariant($product, $variantData);
+
+ if (! $variant instanceof ProductVariant) {
+ continue;
+ }
+
+ $sku = $variantData['sku'] ?? null;
+
+ if ($sku !== null && $sku !== $variant->sku) {
+ $this->assertSkuIsUnique($product->store_id, $sku, $variant->id);
+ }
+
+ $variant->update($this->variantAttributes($variantData));
+
+ if (array_key_exists('inventory', $variantData)) {
+ $variant->inventoryItem()->updateOrCreate(
+ ['variant_id' => $variant->id],
+ array_merge(['store_id' => $product->store_id], $variantData['inventory']),
+ );
+ }
+ }
+ }
+
+ /**
+ * Find the variant an override payload refers to.
+ *
+ * @param array $variantData
+ */
+ private function matchVariant(Product $product, array $variantData): ?ProductVariant
+ {
+ if (! empty($variantData['id'])) {
+ return $product->variants()->whereKey($variantData['id'])->first();
+ }
+
+ if (! empty($variantData['option_values'])) {
+ $wanted = collect($variantData['option_values'])->map(fn ($value) => mb_strtolower(trim((string) $value)))->sort()->values();
+
+ return $product->variants()
+ ->with('optionValues')
+ ->where('status', VariantStatus::Active)
+ ->get()
+ ->first(function (ProductVariant $variant) use ($wanted): bool {
+ $actual = $variant->optionValues->pluck('value')->map(fn ($value) => mb_strtolower(trim((string) $value)))->sort()->values();
+
+ return $actual->all() === $wanted->all();
+ });
+ }
+
+ return null;
+ }
+
+ /**
+ * Extract the variant's own attributes from the payload.
+ *
+ * @return array
+ */
+ private function variantAttributes(array $data): array
+ {
+ return Arr::only($data, [
+ 'sku', 'barcode', 'price_amount', 'compare_at_amount', 'currency',
+ 'weight_g', 'requires_shipping', 'position',
+ ]);
+ }
+
+ /**
+ * Ensure the SKU is unique across all variants of the store.
+ * Null and empty SKUs are exempt.
+ *
+ * @throws ValidationException
+ */
+ private function assertSkuIsUnique(int $storeId, ?string $sku, ?int $excludeVariantId = null): void
+ {
+ if ($sku === null || trim($sku) === '') {
+ return;
+ }
+
+ $exists = DB::table('product_variants')
+ ->join('products', 'products.id', '=', 'product_variants.product_id')
+ ->where('products.store_id', $storeId)
+ ->where('product_variants.sku', $sku)
+ ->when($excludeVariantId !== null, fn ($query) => $query->where('product_variants.id', '!=', $excludeVariantId))
+ ->exists();
+
+ if ($exists) {
+ throw ValidationException::withMessages([
+ 'sku' => ["The SKU '{$sku}' is already used by another variant in this store."],
+ ]);
+ }
+ }
+
+ /**
+ * Ensure the product satisfies the preconditions for activation:
+ * a non-empty title and at least one active variant with a price.
+ *
+ * @throws InvalidProductTransitionException
+ */
+ private function assertPublishable(Product $product, ProductStatus $from): void
+ {
+ if (trim($product->title) === '') {
+ throw InvalidProductTransitionException::transition($from->value, ProductStatus::Active->value, 'The product title must not be empty.');
+ }
+
+ $hasPricedVariant = $product->variants()
+ ->where('status', VariantStatus::Active)
+ ->where('price_amount', '>', 0)
+ ->exists();
+
+ if (! $hasPricedVariant) {
+ throw InvalidProductTransitionException::transition($from->value, ProductStatus::Active->value, 'At least one variant with a price greater than zero is required.');
+ }
+ }
+
+ /**
+ * Ensure no order lines reference the product before reverting to draft.
+ *
+ * @throws InvalidProductTransitionException
+ */
+ private function assertNoOrderReferences(Product $product, ProductStatus $from, ProductStatus $to): void
+ {
+ if ($this->hasOrderReferences($product)) {
+ throw InvalidProductTransitionException::transition($from->value, $to->value, 'The product is referenced by existing orders.');
+ }
+ }
+
+ /**
+ * Whether any order line references the product or one of its variants.
+ */
+ private function hasOrderReferences(Product $product): bool
+ {
+ return DB::table('order_lines')
+ ->where('product_id', $product->id)
+ ->orWhereIn('variant_id', $product->variants()->select('id'))
+ ->exists();
+ }
+}
diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php
new file mode 100644
index 00000000..30def6fc
--- /dev/null
+++ b/app/Services/VariantMatrixService.php
@@ -0,0 +1,144 @@
+options()->with('values')->get();
+
+ if ($options->isEmpty()) {
+ return;
+ }
+
+ $valueSets = $options
+ ->map(fn ($option) => $option->values->pluck('id')->all())
+ ->all();
+
+ $desiredCombos = $this->cartesianProduct($valueSets);
+ $desiredKeys = array_map(fn (array $combo) => $this->comboKey($combo), $desiredCombos);
+
+ $variants = $product->variants()->with('optionValues')->get();
+ $activeVariants = $variants->where('status', VariantStatus::Active)->values();
+
+ $defaults = $this->defaultAttributes($variants->first());
+
+ $existingKeys = $activeVariants
+ ->map(fn (ProductVariant $variant) => $this->comboKey($variant->optionValues->pluck('id')->all()))
+ ->all();
+
+ // Create variants for combinations that do not exist yet.
+ $position = $activeVariants->count();
+ $hasDefault = $variants->contains('is_default', true);
+
+ foreach ($desiredCombos as $index => $combo) {
+ if (in_array($desiredKeys[$index], $existingKeys, true)) {
+ continue;
+ }
+
+ $variant = $product->variants()->create(array_merge($defaults, [
+ 'position' => $position++,
+ 'is_default' => ! $hasDefault,
+ ]));
+ $hasDefault = true;
+
+ $variant->optionValues()->sync($combo);
+ $variant->inventoryItem()->create([
+ 'store_id' => $product->store_id,
+ 'quantity_on_hand' => 0,
+ ]);
+ }
+
+ // Remove variants that no longer match any desired combination.
+ foreach ($activeVariants as $variant) {
+ $key = $this->comboKey($variant->optionValues->pluck('id')->all());
+
+ if (in_array($key, $desiredKeys, true)) {
+ continue;
+ }
+
+ if ($this->hasOrderLineReferences($variant)) {
+ $variant->update(['status' => VariantStatus::Archived]);
+ } else {
+ $variant->delete();
+ }
+ }
+ });
+ }
+
+ /**
+ * Compute the cartesian product of the given sets of option value IDs.
+ *
+ * @param list> $sets
+ * @return list>
+ */
+ private function cartesianProduct(array $sets): array
+ {
+ $result = [[]];
+
+ foreach ($sets as $set) {
+ $next = [];
+
+ foreach ($result as $combination) {
+ foreach ($set as $valueId) {
+ $next[] = array_merge($combination, [$valueId]);
+ }
+ }
+
+ $result = $next;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Build a normalized comparison key for a combination of value IDs.
+ *
+ * @param list $valueIds
+ */
+ private function comboKey(array $valueIds): string
+ {
+ sort($valueIds);
+
+ return implode('-', $valueIds);
+ }
+
+ /**
+ * Pricing/shipping defaults copied from the first existing variant.
+ *
+ * @return array
+ */
+ private function defaultAttributes(?ProductVariant $reference): array
+ {
+ return [
+ 'price_amount' => $reference?->price_amount ?? 0,
+ 'compare_at_amount' => $reference?->compare_at_amount,
+ 'currency' => $reference?->currency ?? 'USD',
+ 'weight_g' => $reference?->weight_g,
+ 'requires_shipping' => $reference?->requires_shipping ?? true,
+ ];
+ }
+
+ /**
+ * Whether any order line references the variant.
+ */
+ private function hasOrderLineReferences(ProductVariant $variant): bool
+ {
+ return DB::table('order_lines')->where('variant_id', $variant->id)->exists();
+ }
+}
diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php
new file mode 100644
index 00000000..2687400c
--- /dev/null
+++ b/app/Support/HandleGenerator.php
@@ -0,0 +1,46 @@
+where('store_id', $storeId)
+ ->where('handle', $handle)
+ ->when($excludeId !== null, fn ($query) => $query->where('id', '!=', $excludeId))
+ ->exists();
+ }
+}
diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php
new file mode 100644
index 00000000..aa643236
--- /dev/null
+++ b/database/factories/CollectionFactory.php
@@ -0,0 +1,51 @@
+
+ */
+class CollectionFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'title' => fake()->unique()->words(2, true),
+ 'handle' => fake()->unique()->slug(2),
+ 'description_html' => null,
+ 'type' => CollectionType::Manual,
+ 'status' => CollectionStatus::Active,
+ ];
+ }
+
+ /**
+ * Indicate that the collection is rule-based.
+ */
+ public function automated(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => CollectionType::Automated,
+ ]);
+ }
+
+ /**
+ * Indicate that the collection is a draft.
+ */
+ public function draft(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => CollectionStatus::Draft,
+ ]);
+ }
+}
diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php
new file mode 100644
index 00000000..96a8ea86
--- /dev/null
+++ b/database/factories/InventoryItemFactory.php
@@ -0,0 +1,40 @@
+
+ */
+class InventoryItemFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'variant_id' => ProductVariant::factory(),
+ 'quantity_on_hand' => 10,
+ 'quantity_reserved' => 0,
+ 'policy' => InventoryPolicy::Deny,
+ ];
+ }
+
+ /**
+ * Indicate that overselling is allowed (backorders).
+ */
+ public function backorderable(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'policy' => InventoryPolicy::Continue,
+ ]);
+ }
+}
diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php
new file mode 100644
index 00000000..22095c16
--- /dev/null
+++ b/database/factories/ProductFactory.php
@@ -0,0 +1,88 @@
+
+ */
+class ProductFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ $title = fake()->unique()->words(3, true);
+
+ return [
+ 'store_id' => Store::factory(),
+ 'title' => $title,
+ 'handle' => fake()->unique()->slug(2),
+ 'status' => ProductStatus::Draft,
+ 'description_html' => ''.fake()->sentence().'
',
+ 'vendor' => fake()->company(),
+ 'product_type' => fake()->word(),
+ 'tags' => [],
+ 'published_at' => null,
+ ];
+ }
+
+ /**
+ * Indicate that the product is a draft.
+ */
+ public function draft(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => ProductStatus::Draft,
+ 'published_at' => null,
+ ]);
+ }
+
+ /**
+ * Indicate that the product is active and published.
+ */
+ public function active(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => ProductStatus::Active,
+ 'published_at' => now(),
+ ]);
+ }
+
+ /**
+ * Indicate that the product is archived.
+ */
+ public function archived(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => ProductStatus::Archived,
+ ]);
+ }
+
+ /**
+ * Create variants (each with an inventory item) for the product.
+ */
+ public function withVariants(int $count = 1, array $attributes = []): static
+ {
+ return $this->afterCreating(function (\App\Models\Product $product) use ($count, $attributes): void {
+ for ($i = 0; $i < $count; $i++) {
+ $variant = $product->variants()->create(array_merge([
+ 'price_amount' => fake()->numberBetween(100, 10000),
+ 'position' => $i,
+ 'is_default' => $i === 0,
+ ], $attributes));
+
+ $variant->inventoryItem()->create([
+ 'store_id' => $product->store_id,
+ 'quantity_on_hand' => 10,
+ ]);
+ }
+ });
+ }
+}
diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php
new file mode 100644
index 00000000..d0e14939
--- /dev/null
+++ b/database/factories/ProductMediaFactory.php
@@ -0,0 +1,41 @@
+
+ */
+class ProductMediaFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'product_id' => Product::factory(),
+ 'type' => MediaType::Image,
+ 'storage_key' => 'media/originals/'.fake()->uuid().'.jpg',
+ 'alt_text' => fake()->sentence(),
+ 'position' => 0,
+ 'status' => MediaStatus::Processing,
+ ];
+ }
+
+ /**
+ * Indicate that the media finished processing.
+ */
+ public function ready(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => MediaStatus::Ready,
+ ]);
+ }
+}
diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php
new file mode 100644
index 00000000..02279ff9
--- /dev/null
+++ b/database/factories/ProductOptionFactory.php
@@ -0,0 +1,26 @@
+
+ */
+class ProductOptionFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'product_id' => Product::factory(),
+ 'name' => fake()->randomElement(['Size', 'Color', 'Material']),
+ 'position' => 0,
+ ];
+ }
+}
diff --git a/database/factories/ProductOptionValueFactory.php b/database/factories/ProductOptionValueFactory.php
new file mode 100644
index 00000000..a47769fb
--- /dev/null
+++ b/database/factories/ProductOptionValueFactory.php
@@ -0,0 +1,26 @@
+
+ */
+class ProductOptionValueFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'product_option_id' => ProductOption::factory(),
+ 'value' => fake()->word(),
+ 'position' => 0,
+ ];
+ }
+}
diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php
new file mode 100644
index 00000000..e11b3fbc
--- /dev/null
+++ b/database/factories/ProductVariantFactory.php
@@ -0,0 +1,68 @@
+
+ */
+class ProductVariantFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'product_id' => Product::factory(),
+ 'sku' => fake()->unique()->bothify('SKU-####'),
+ 'barcode' => null,
+ 'price_amount' => fake()->numberBetween(100, 10000),
+ 'compare_at_amount' => null,
+ 'currency' => 'USD',
+ 'weight_g' => null,
+ 'requires_shipping' => true,
+ 'is_default' => false,
+ 'position' => 0,
+ 'status' => VariantStatus::Active,
+ ];
+ }
+
+ /**
+ * Indicate that the variant is the product's default variant.
+ */
+ public function default(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_default' => true,
+ ]);
+ }
+
+ /**
+ * Indicate that the variant is archived.
+ */
+ public function archived(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => VariantStatus::Archived,
+ ]);
+ }
+
+ /**
+ * Create an inventory item for the variant.
+ */
+ public function withInventory(int $quantityOnHand = 10): static
+ {
+ return $this->afterCreating(function (\App\Models\ProductVariant $variant) use ($quantityOnHand): void {
+ $variant->inventoryItem()->create([
+ 'store_id' => $variant->product->store_id,
+ 'quantity_on_hand' => $quantityOnHand,
+ ]);
+ });
+ }
+}
diff --git a/specs/progress.md b/specs/progress.md
index 89d16bfa..7a7e9404 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -15,30 +15,32 @@
| # | Date | Phase | Summary | Commit |
|---|------|-------|---------|--------|
| 0 | 2026-07-19 | Setup | Project scaffolding: sanctum + pest-plugin-browser deps, .env.testing, Herd site links (acme-fashion.test, acme-electronics.test), starter-kit cleanup, Fortify removed | c002d312 |
-| 1 | 2026-07-19 | Phase 1 | Foundation: all 46-table migrations (+users rewrite), 28 enums, core models (Organization/Store/StoreDomain/StoreSettings/StoreUser/User/Customer), BelongsToStore+StoreScope, ResolveStore/CheckStoreRole/CustomerAuthenticate middleware, CustomerUserProvider, 11 policies, 9 gates, 7 rate limiters, route wiring (web/admin/api). 15 tests green. | pending |
+| 1 | 2026-07-19 | Phase 1 | Foundation: all 46-table migrations (+users rewrite), 28 enums, core models (Organization/Store/StoreDomain/StoreSettings/StoreUser/User/Customer), BelongsToStore+StoreScope, ResolveStore/CheckStoreRole/CustomerAuthenticate middleware, CustomerUserProvider, 11 policies, 9 gates, 7 rate limiters, route wiring (web/admin/api). 15 tests green. | 342ff904 |
---
+| 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 |
+
## Phase Checklist
### Phase 1 — Foundation
-- [ ] Config: database pragmas, session, cache, queue, auth (customer guard), logging (json + audit), cors
-- [ ] All 46-table migrations (spec 01, dependency order)
-- [ ] Core models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User
-- [ ] Enums (all, spec 05 §21)
-- [ ] ResolveStore / CheckStoreRole / CustomerAuthenticate middleware
-- [ ] BelongsToStore trait + StoreScope
-- [ ] CustomerUserProvider
-- [ ] Policies + ChecksStoreRole trait + Gates
-- [ ] Rate limiters
-- [ ] Tests: Tenancy (TenantResolutionTest, StoreIsolationTest)
+- [x] Config: database pragmas, session, cache, queue, auth (customer guard), logging (json + audit), cors
+- [x] All 46-table migrations (spec 01, dependency order)
+- [x] Core models: Organization, Store, StoreDomain, StoreUser, StoreSettings, User
+- [x] Enums (all, spec 05 §21)
+- [x] ResolveStore / CheckStoreRole / CustomerAuthenticate middleware
+- [x] BelongsToStore trait + StoreScope
+- [x] CustomerUserProvider
+- [x] Policies + ChecksStoreRole trait + Gates
+- [x] Rate limiters
+- [x] Tests: Tenancy (TenantResolutionTest, StoreIsolationTest)
### Phase 2 — Catalog
-- [ ] Models: Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia
-- [ ] ProductService, VariantMatrixService, InventoryService, HandleGenerator
-- [ ] ProcessMediaUpload job
-- [ ] SanitizeHtml action
-- [ ] Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest, HandleGeneratorTest
+- [x] Models: Product, ProductOption, ProductOptionValue, ProductVariant, InventoryItem, Collection, ProductMedia
+- [x] ProductService, VariantMatrixService, InventoryService, HandleGenerator
+- [x] ProcessMediaUpload job
+- [x] SanitizeHtml action
+- [x] Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest, HandleGeneratorTest
### Phase 3 — Themes / Pages / Navigation / Storefront layout
- [ ] Models: Theme, ThemeFile, ThemeSettings, Page, NavigationMenu, NavigationItem
diff --git a/tests/Feature/Products/CollectionTest.php b/tests/Feature/Products/CollectionTest.php
new file mode 100644
index 00000000..713cc203
--- /dev/null
+++ b/tests/Feature/Products/CollectionTest.php
@@ -0,0 +1,79 @@
+createStore();
+ $collection = Collection::factory()->create(['store_id' => $store->id]);
+
+ $first = Product::factory()->create(['store_id' => $store->id]);
+ $second = Product::factory()->create(['store_id' => $store->id]);
+ $third = Product::factory()->create(['store_id' => $store->id]);
+
+ $collection->products()->attach($first->id, ['position' => 2]);
+ $collection->products()->attach($second->id, ['position' => 0]);
+ $collection->products()->attach($third->id, ['position' => 1]);
+
+ $ordered = $collection->products()->pluck('products.id')->all();
+
+ expect($ordered)->toBe([$second->id, $third->id, $first->id]);
+});
+
+test('products detach from a collection', function () {
+ $store = $this->createStore();
+ $collection = Collection::factory()->create(['store_id' => $store->id]);
+ $product = Product::factory()->create(['store_id' => $store->id]);
+
+ $collection->products()->attach($product->id, ['position' => 0]);
+
+ expect($collection->products()->count())->toBe(1);
+
+ $collection->products()->detach($product->id);
+
+ expect($collection->products()->count())->toBe(0);
+});
+
+test('the inverse relation lists collections of a product ordered by position', function () {
+ $store = $this->createStore();
+ $product = Product::factory()->create(['store_id' => $store->id]);
+ $collectionA = Collection::factory()->create(['store_id' => $store->id]);
+ $collectionB = Collection::factory()->create(['store_id' => $store->id]);
+
+ $product->collections()->attach($collectionA->id, ['position' => 1]);
+ $product->collections()->attach($collectionB->id, ['position' => 0]);
+
+ expect($product->collections()->pluck('collections.id')->all())
+ ->toBe([$collectionB->id, $collectionA->id]);
+});
+
+test('collections are scoped to the bound store', function () {
+ $storeA = $this->createStore();
+ $storeB = $this->createStore();
+
+ Collection::factory()->count(2)->create(['store_id' => $storeA->id]);
+ Collection::factory()->count(3)->create(['store_id' => $storeB->id]);
+
+ $this->bindStore($storeA);
+
+ expect(Collection::query()->count())->toBe(2);
+});
+
+test('collection casts expose enums', function () {
+ $collection = Collection::factory()->automated()->create();
+
+ expect($collection->type)->toBe(CollectionType::Automated)
+ ->and($collection->status)->toBe(CollectionStatus::Active);
+});
+
+test('pivot position is exposed on the relation', function () {
+ $store = $this->createStore();
+ $collection = Collection::factory()->create(['store_id' => $store->id]);
+ $product = Product::factory()->create(['store_id' => $store->id]);
+
+ $collection->products()->attach($product->id, ['position' => 5]);
+
+ expect($collection->products->first()->pivot->position)->toBe(5);
+});
diff --git a/tests/Feature/Products/InventoryTest.php b/tests/Feature/Products/InventoryTest.php
new file mode 100644
index 00000000..a480a10f
--- /dev/null
+++ b/tests/Feature/Products/InventoryTest.php
@@ -0,0 +1,130 @@
+store = $this->createStore();
+ $this->service = app(InventoryService::class);
+});
+
+function makeInventory(App\Models\Store $store, array $attributes = []): InventoryItem
+{
+ $variant = ProductVariant::factory()->for(
+ App\Models\Product::factory()->create(['store_id' => $store->id])
+ )->create();
+
+ return InventoryItem::factory()->create(array_merge([
+ 'store_id' => $store->id,
+ 'variant_id' => $variant->id,
+ 'quantity_on_hand' => 10,
+ 'quantity_reserved' => 0,
+ ], $attributes));
+}
+
+test('available is on hand minus reserved', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 10, 'quantity_reserved' => 3]);
+
+ expect($item->available())->toBe(7);
+});
+
+test('reserve increments the reserved quantity', function () {
+ $item = makeInventory($this->store);
+
+ $this->service->reserve($item, 4);
+
+ $item->refresh();
+
+ expect($item->quantity_reserved)->toBe(4)
+ ->and($item->quantity_on_hand)->toBe(10)
+ ->and($item->available())->toBe(6);
+});
+
+test('reserve with deny policy throws when overselling', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 2, 'policy' => InventoryPolicy::Deny]);
+
+ $this->service->reserve($item, 3);
+})->throws(InsufficientInventoryException::class);
+
+test('reserve with continue policy allows overselling', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 2, 'policy' => InventoryPolicy::Continue]);
+
+ $this->service->reserve($item, 5);
+
+ expect($item->refresh()->quantity_reserved)->toBe(5)
+ ->and($item->available())->toBe(-3);
+});
+
+test('checkAvailability honors the policy', function () {
+ $denyItem = makeInventory($this->store, ['quantity_on_hand' => 2, 'policy' => InventoryPolicy::Deny]);
+ $continueItem = makeInventory($this->store, ['quantity_on_hand' => 0, 'policy' => InventoryPolicy::Continue]);
+
+ expect($this->service->checkAvailability($denyItem, 2))->toBeTrue()
+ ->and($this->service->checkAvailability($denyItem, 3))->toBeFalse()
+ ->and($this->service->checkAvailability($continueItem, 100))->toBeTrue();
+});
+
+test('release decrements the reserved quantity', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 10, 'quantity_reserved' => 6]);
+
+ $this->service->release($item, 4);
+
+ expect($item->refresh()->quantity_reserved)->toBe(2)
+ ->and($item->quantity_on_hand)->toBe(10);
+});
+
+test('commit decrements both on hand and reserved', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 10, 'quantity_reserved' => 5]);
+
+ $this->service->commit($item, 3);
+
+ $item->refresh();
+
+ expect($item->quantity_on_hand)->toBe(7)
+ ->and($item->quantity_reserved)->toBe(2);
+});
+
+test('restock increments the on hand quantity', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 4, 'quantity_reserved' => 2]);
+
+ $this->service->restock($item, 6);
+
+ $item->refresh();
+
+ expect($item->quantity_on_hand)->toBe(10)
+ ->and($item->quantity_reserved)->toBe(2);
+});
+
+test('the full stock lifecycle arithmetic works out', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 10]);
+
+ $this->service->reserve($item, 3);
+ $this->service->reserve($item->refresh(), 2);
+ $this->service->commit($item->refresh(), 3);
+ $this->service->release($item->refresh(), 2);
+ $this->service->restock($item->refresh(), 1);
+
+ $item->refresh();
+
+ expect($item->quantity_on_hand)->toBe(8)
+ ->and($item->quantity_reserved)->toBe(0)
+ ->and($item->available())->toBe(8);
+});
+
+test('variant helpers expose stock state', function () {
+ $item = makeInventory($this->store, ['quantity_on_hand' => 5, 'quantity_reserved' => 2]);
+
+ $variant = $item->variant->refresh();
+
+ expect($variant->availableQuantity())->toBe(3)
+ ->and($variant->isInStock())->toBeTrue()
+ ->and($variant->isBackorderable())->toBeFalse();
+
+ $backorderable = makeInventory($this->store, ['quantity_on_hand' => 0, 'policy' => InventoryPolicy::Continue]);
+
+ expect($backorderable->variant->refresh()->isBackorderable())->toBeTrue()
+ ->and($backorderable->variant->isInStock())->toBeFalse();
+});
diff --git a/tests/Feature/Products/MediaUploadTest.php b/tests/Feature/Products/MediaUploadTest.php
new file mode 100644
index 00000000..d6e4aa36
--- /dev/null
+++ b/tests/Feature/Products/MediaUploadTest.php
@@ -0,0 +1,150 @@
+store = $this->createStore();
+ $this->product = Product::factory()->create(['store_id' => $this->store->id]);
+});
+
+/**
+ * Write a real JPEG image of the given size to the fake public disk.
+ */
+function createTestImage(string $storageKey, int $width, int $height): void
+{
+ $image = imagecreatetruecolor($width, $height);
+ imagefill($image, 0, 0, imagecolorallocate($image, 120, 80, 200));
+
+ $path = Storage::disk('public')->path($storageKey);
+
+ if (! is_dir(dirname($path))) {
+ mkdir(dirname($path), 0755, true);
+ }
+
+ imagejpeg($image, $path, 90);
+ imagedestroy($image);
+}
+
+test('processing generates all four sizes with contained dimensions', function () {
+ createTestImage('media/originals/photo.jpg', 800, 600);
+
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/photo.jpg',
+ ]);
+
+ (new ProcessMediaUpload($media))->handle();
+
+ $expected = [
+ 'thumbnail' => [150, 113],
+ 'small' => [300, 225],
+ 'medium' => [600, 450],
+ 'large' => [800, 600], // never upscaled
+ ];
+
+ foreach ($expected as $size => [$expectedWidth, $expectedHeight]) {
+ $path = $media->pathFor($size);
+
+ expect(Storage::disk('public')->exists($path))->toBeTrue("Missing {$size}");
+
+ [$width, $height] = getimagesize(Storage::disk('public')->path($path));
+
+ expect($width)->toBe($expectedWidth, "Wrong width for {$size}")
+ ->and($height)->toBe($expectedHeight, "Wrong height for {$size}");
+ }
+});
+
+test('processing updates the record to ready with original metadata', function () {
+ createTestImage('media/originals/meta.jpg', 1024, 768);
+
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/meta.jpg',
+ ]);
+
+ (new ProcessMediaUpload($media))->handle();
+
+ $media->refresh();
+
+ expect($media->status)->toBe(MediaStatus::Ready)
+ ->and($media->width)->toBe(1024)
+ ->and($media->height)->toBe(768)
+ ->and($media->mime_type)->toBe('image/jpeg')
+ ->and($media->byte_size)->toBe(Storage::disk('public')->size('media/originals/meta.jpg'));
+});
+
+test('aspect ratio is preserved for portrait images', function () {
+ createTestImage('media/originals/portrait.jpg', 600, 1200);
+
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/portrait.jpg',
+ ]);
+
+ (new ProcessMediaUpload($media))->handle();
+
+ [$width, $height] = getimagesize(Storage::disk('public')->path($media->pathFor('medium')));
+
+ expect($width)->toBe(300)
+ ->and($height)->toBe(600)
+ ->and($width / $height)->toEqualWithDelta(600 / 1200, 0.01);
+});
+
+test('garbage bytes mark the media as failed', function () {
+ Storage::disk('public')->put('media/originals/broken.jpg', 'this is not an image');
+
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/broken.jpg',
+ ]);
+
+ $job = new ProcessMediaUpload($media);
+
+ try {
+ $job->handle();
+ $this->fail('Expected a RuntimeException for corrupt image data.');
+ } catch (RuntimeException $exception) {
+ $job->failed($exception);
+ }
+
+ expect($media->refresh()->status)->toBe(MediaStatus::Failed)
+ // The original is kept for inspection.
+ ->and(Storage::disk('public')->exists('media/originals/broken.jpg'))->toBeTrue();
+});
+
+test('deleting the media record removes all files from storage', function () {
+ createTestImage('media/originals/cleanup.jpg', 400, 400);
+
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/cleanup.jpg',
+ ]);
+
+ (new ProcessMediaUpload($media))->handle();
+
+ expect(Storage::disk('public')->exists($media->pathFor('thumbnail')))->toBeTrue();
+
+ $media->delete();
+
+ expect(Storage::disk('public')->exists('media/originals/cleanup.jpg'))->toBeFalse()
+ ->and(Storage::disk('public')->exists($media->pathFor('thumbnail')))->toBeFalse()
+ ->and(Storage::disk('public')->exists($media->pathFor('small')))->toBeFalse()
+ ->and(Storage::disk('public')->exists($media->pathFor('medium')))->toBeFalse()
+ ->and(Storage::disk('public')->exists($media->pathFor('large')))->toBeFalse();
+});
+
+test('media urls point at the public disk paths', function () {
+ $media = ProductMedia::factory()->create([
+ 'product_id' => $this->product->id,
+ 'storage_key' => 'media/originals/u.jpg',
+ ]);
+
+ expect($media->url())->toContain('media/originals/u.jpg')
+ ->and($media->urlFor('medium'))->toContain("media/{$this->product->id}/{$media->id}/medium.jpg");
+});
diff --git a/tests/Feature/Products/ProductCrudTest.php b/tests/Feature/Products/ProductCrudTest.php
new file mode 100644
index 00000000..f49b1984
--- /dev/null
+++ b/tests/Feature/Products/ProductCrudTest.php
@@ -0,0 +1,275 @@
+store = $this->createStore();
+ $this->service = app(ProductService::class);
+});
+
+test('it creates a product with nested options, variants, and inventory', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'T-Shirt',
+ 'vendor' => 'Acme',
+ 'tags' => ['summer', 'sale'],
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue', 'Red']],
+ ['name' => 'Size', 'values' => ['Small', 'Medium', 'Large']],
+ ],
+ 'variant_defaults' => ['price_amount' => 2500, 'currency' => 'USD'],
+ 'variants' => [
+ ['option_values' => ['Blue', 'Small'], 'sku' => 'TS-BLU-S', 'inventory' => ['quantity_on_hand' => 5]],
+ ],
+ ]);
+
+ expect($product)->toBeInstanceOf(Product::class)
+ ->and($product->store_id)->toBe($this->store->id)
+ ->and($product->status)->toBe(ProductStatus::Draft)
+ ->and($product->handle)->toBe('t-shirt')
+ ->and($product->options)->toHaveCount(2)
+ ->and($product->options->pluck('name')->all())->toBe(['Color', 'Size'])
+ ->and($product->options->first()->values->pluck('value')->all())->toBe(['Blue', 'Red'])
+ // 2 colors x 3 sizes
+ ->and($product->variants)->toHaveCount(6);
+
+ $blueSmall = $product->variants->firstWhere('sku', 'TS-BLU-S');
+
+ expect($blueSmall)->not->toBeNull()
+ ->and($blueSmall->price_amount)->toBe(2500)
+ ->and($blueSmall->title())->toBe('Blue / Small')
+ ->and($blueSmall->inventoryItem->quantity_on_hand)->toBe(5)
+ ->and($blueSmall->inventoryItem->store_id)->toBe($this->store->id);
+
+ // Every variant got an inventory item.
+ expect($product->variants->every(fn ($variant) => $variant->inventoryItem !== null))->toBeTrue();
+
+ // Exactly one default variant exists.
+ expect($product->variants->where('is_default', true))->toHaveCount(1)
+ ->and($product->defaultVariant)->not->toBeNull();
+});
+
+test('it creates a single default variant when no options are given', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Simple Product',
+ 'variants' => [
+ ['sku' => 'SIMPLE-1', 'price_amount' => 999, 'inventory' => ['quantity_on_hand' => 3]],
+ ],
+ ]);
+
+ expect($product->variants)->toHaveCount(1);
+
+ $variant = $product->variants->first();
+
+ expect($variant->is_default)->toBeTrue()
+ ->and($variant->sku)->toBe('SIMPLE-1')
+ ->and($variant->price_amount)->toBe(999)
+ ->and($variant->title())->toBe('Default')
+ ->and($variant->inventoryItem->quantity_on_hand)->toBe(3);
+});
+
+test('it auto-generates unique handles per store', function () {
+ $first = $this->service->create($this->store, ['title' => 'Mug']);
+ $second = $this->service->create($this->store, ['title' => 'Mug']);
+
+ expect($first->handle)->toBe('mug')
+ ->and($second->handle)->toBe('mug-1');
+
+ $otherStore = $this->createStore();
+ $third = $this->service->create($otherStore, ['title' => 'Mug']);
+
+ expect($third->handle)->toBe('mug');
+});
+
+test('it sanitizes the description html on create and update', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'XSS Test',
+ 'description_html' => 'Nice
',
+ ]);
+
+ expect($product->description_html)
+ ->toContain('Nice
')
+ ->not->toContain('script');
+
+ $product = $this->service->update($product, [
+ 'description_html' => 'Updated
',
+ ]);
+
+ expect($product->description_html)
+ ->toContain('Updated
')
+ ->not->toContain('onclick');
+});
+
+test('it updates scalar attributes and re-validates a manual handle', function () {
+ $product = $this->service->create($this->store, ['title' => 'One']);
+ $other = $this->service->create($this->store, ['title' => 'Two']);
+
+ $updated = $this->service->update($other, [
+ 'title' => 'Two Renamed',
+ 'vendor' => 'New Vendor',
+ 'handle' => 'one', // collides with the first product
+ ]);
+
+ expect($updated->title)->toBe('Two Renamed')
+ ->and($updated->vendor)->toBe('New Vendor')
+ ->and($updated->handle)->toBe('one-1');
+});
+
+test('it rejects duplicate SKUs within the same store but allows empty SKUs', function () {
+ $this->service->create($this->store, [
+ 'title' => 'First',
+ 'variants' => [['sku' => 'DUP-1', 'price_amount' => 100]],
+ ]);
+
+ $this->service->create($this->store, [
+ 'title' => 'Second',
+ 'variants' => [['sku' => 'DUP-1', 'price_amount' => 100]],
+ ]);
+})->throws(Illuminate\Validation\ValidationException::class);
+
+test('it allows the same SKU in different stores', function () {
+ $this->service->create($this->store, [
+ 'title' => 'First',
+ 'variants' => [['sku' => 'SHARED-1', 'price_amount' => 100]],
+ ]);
+
+ $otherStore = $this->createStore();
+ $product = $this->service->create($otherStore, [
+ 'title' => 'Second',
+ 'variants' => [['sku' => 'SHARED-1', 'price_amount' => 100]],
+ ]);
+
+ expect($product->variants->first()->sku)->toBe('SHARED-1');
+});
+
+test('a draft product becomes active when preconditions are met and sets published_at', function () {
+ Event::fake();
+
+ $product = $this->service->create($this->store, [
+ 'title' => 'Publishable',
+ 'variants' => [['price_amount' => 500]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+
+ $product->refresh();
+
+ expect($product->status)->toBe(ProductStatus::Active)
+ ->and($product->published_at)->not->toBeNull();
+
+ Event::assertDispatched(App\Events\ProductStatusChanged::class);
+});
+
+test('activation is blocked without a priced variant or title', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'No Price',
+ 'variants' => [['price_amount' => 0]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+})->throws(InvalidProductTransitionException::class);
+
+test('archived products can be re-published and drafts archived', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Lifecycle',
+ 'variants' => [['price_amount' => 100]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+ $this->service->transitionStatus($product->refresh(), ProductStatus::Archived);
+
+ expect($product->refresh()->status)->toBe(ProductStatus::Archived);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+
+ expect($product->refresh()->status)->toBe(ProductStatus::Active);
+});
+
+test('reverting to draft is blocked when order lines reference the product', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Ordered',
+ 'variants' => [['price_amount' => 100]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+
+ createOrderLineFor($this->store, $product, $product->variants->first());
+
+ $this->service->transitionStatus($product->refresh(), ProductStatus::Draft);
+})->throws(InvalidProductTransitionException::class);
+
+test('reverting to draft works without order references', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Revertible',
+ 'variants' => [['price_amount' => 100]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+ $this->service->transitionStatus($product->refresh(), ProductStatus::Draft);
+
+ expect($product->refresh()->status)->toBe(ProductStatus::Draft);
+});
+
+test('a draft product without order references can be hard deleted', function () {
+ Event::fake();
+
+ $product = $this->service->create($this->store, ['title' => 'Deletable']);
+
+ $this->service->delete($product);
+
+ expect(Product::query()->find($product->id))->toBeNull();
+
+ Event::assertDispatched(App\Events\ProductDeleted::class);
+});
+
+test('non-draft products cannot be deleted', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Active Product',
+ 'variants' => [['price_amount' => 100]],
+ ]);
+
+ $this->service->transitionStatus($product, ProductStatus::Active);
+
+ $this->service->delete($product->refresh());
+})->throws(InvalidProductTransitionException::class);
+
+test('draft products referenced by orders cannot be deleted', function () {
+ $product = $this->service->create($this->store, ['title' => 'Referenced Draft']);
+
+ createOrderLineFor($this->store, $product, $product->variants->first());
+
+ $this->service->delete($product);
+})->throws(InvalidProductTransitionException::class);
+
+test('products dispatch created and updated events', function () {
+ Event::fake();
+
+ $product = $this->service->create($this->store, ['title' => 'Events']);
+ $this->service->update($product, ['vendor' => 'Acme']);
+
+ Event::assertDispatched(App\Events\ProductCreated::class);
+ Event::assertDispatched(App\Events\ProductUpdated::class);
+});
+
+test('the visible scope only returns active and published products', function () {
+ $store = $this->store;
+
+ $draft = Product::factory()->draft()->create(['store_id' => $store->id]);
+ $active = Product::factory()->active()->create(['store_id' => $store->id]);
+ $archived = Product::factory()->archived()->create(['store_id' => $store->id, 'published_at' => now()]);
+ $activeUnpublished = Product::factory()->create([
+ 'store_id' => $store->id,
+ 'status' => ProductStatus::Active,
+ 'published_at' => null,
+ ]);
+
+ $visible = Product::query()->visible()->pluck('id');
+
+ expect($visible)->toContain($active->id)
+ ->not->toContain($draft->id)
+ ->not->toContain($archived->id)
+ ->not->toContain($activeUnpublished->id);
+});
diff --git a/tests/Feature/Products/VariantTest.php b/tests/Feature/Products/VariantTest.php
new file mode 100644
index 00000000..a37124a8
--- /dev/null
+++ b/tests/Feature/Products/VariantTest.php
@@ -0,0 +1,198 @@
+store = $this->createStore();
+ $this->service = app(ProductService::class);
+});
+
+test('rebuilding the matrix creates missing combinations', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Matrix',
+ 'options' => [
+ ['name' => 'Size', 'values' => ['Small', 'Medium']],
+ ],
+ 'variant_defaults' => ['price_amount' => 1000],
+ ]);
+
+ expect($product->variants)->toHaveCount(2);
+
+ // Add a third value and rebuild.
+ $product = $this->service->update($product, [
+ 'options' => [
+ ['name' => 'Size', 'values' => ['Small', 'Medium', 'Large']],
+ ],
+ ]);
+
+ expect($product->variants)->toHaveCount(3)
+ ->and($product->variants->pluck('optionValues')->map(
+ fn ($values) => $values->first()->value
+ )->sort()->values()->all())->toBe(['Large', 'Medium', 'Small']);
+});
+
+test('rebuilding preserves existing variants: price, sku, and inventory stay intact', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Preserve',
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue', 'Red']],
+ ],
+ 'variants' => [
+ ['option_values' => ['Blue'], 'sku' => 'P-BLU', 'price_amount' => 1500, 'inventory' => ['quantity_on_hand' => 7]],
+ ],
+ ]);
+
+ $blue = $product->variants->firstWhere('sku', 'P-BLU');
+
+ $product = $this->service->update($product, [
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue', 'Red', 'Green']],
+ ],
+ ]);
+
+ $blueAfter = $product->variants->firstWhere('sku', 'P-BLU');
+
+ expect($blueAfter)->not->toBeNull()
+ ->and($blueAfter->id)->toBe($blue->id)
+ ->and($blueAfter->price_amount)->toBe(1500)
+ ->and($blueAfter->inventoryItem->quantity_on_hand)->toBe(7)
+ ->and($product->variants)->toHaveCount(3);
+});
+
+test('orphaned variants without order references are deleted', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Shrink',
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue', 'Red']],
+ ],
+ ]);
+
+ expect($product->variants)->toHaveCount(2);
+
+ $product = $this->service->update($product, [
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue']],
+ ],
+ ]);
+
+ expect($product->variants)->toHaveCount(1)
+ ->and($product->variants->first()->title())->toBe('Blue');
+});
+
+test('orphaned variants with order references are archived, not deleted', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Archive Orphans',
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue', 'Red']],
+ ],
+ ]);
+
+ $red = $product->variants->first(fn ($variant) => $variant->title() === 'Red');
+
+ createOrderLineFor($this->store, $product, $red);
+
+ $product = $this->service->update($product, [
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue']],
+ ],
+ ]);
+
+ $redAfter = $product->variants()->whereKey($red->id)->first();
+
+ expect($redAfter)->not->toBeNull()
+ ->and($redAfter->status)->toBe(VariantStatus::Archived)
+ ->and($product->variants->where('status', VariantStatus::Active))->toHaveCount(1);
+});
+
+test('new variants copy default pricing from the first existing variant', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Defaults',
+ 'options' => [
+ ['name' => 'Size', 'values' => ['Small']],
+ ],
+ 'variants' => [
+ ['option_values' => ['Small'], 'price_amount' => 4321, 'currency' => 'EUR'],
+ ],
+ ]);
+
+ $product = $this->service->update($product, [
+ 'options' => [
+ ['name' => 'Size', 'values' => ['Small', 'Large']],
+ ],
+ ]);
+
+ $large = $product->variants->first(fn ($variant) => $variant->title() === 'Large');
+
+ expect($large)->not->toBeNull()
+ ->and($large->price_amount)->toBe(4321)
+ ->and($large->currency)->toBe('EUR')
+ ->and($large->inventoryItem)->not->toBeNull();
+});
+
+test('a variant with the same SKU in the same store is rejected on update', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'SKU Clash',
+ 'options' => [
+ ['name' => 'Size', 'values' => ['Small', 'Large']],
+ ],
+ 'variants' => [
+ ['option_values' => ['Small'], 'sku' => 'CLASH-S'],
+ ],
+ ]);
+
+ $large = $product->variants->first(fn ($variant) => $variant->title() === 'Large');
+
+ $this->service->update($product, [
+ 'variants' => [
+ ['id' => $large->id, 'sku' => 'CLASH-S'],
+ ],
+ ]);
+})->throws(Illuminate\Validation\ValidationException::class);
+
+test('keeping the same SKU on the same variant is allowed', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'SKU Keep',
+ 'variants' => [['sku' => 'KEEP-1', 'price_amount' => 100]],
+ ]);
+
+ $variant = $product->variants->first();
+
+ $product = $this->service->update($product, [
+ 'variants' => [
+ ['id' => $variant->id, 'sku' => 'KEEP-1', 'price_amount' => 200],
+ ],
+ ]);
+
+ expect($product->variants->first()->price_amount)->toBe(200);
+});
+
+test('rebuildMatrix is a no-op for products without options', function () {
+ $product = $this->service->create($this->store, ['title' => 'No Options']);
+
+ app(VariantMatrixService::class)->rebuildMatrix($product);
+
+ expect($product->refresh()->variants)->toHaveCount(1)
+ ->and($product->variants->first()->is_default)->toBeTrue();
+});
+
+test('a single default variant is auto-created when no options are given', function () {
+ $product = $this->service->create($this->store, ['title' => 'Default Only']);
+
+ expect($product->variants)->toHaveCount(1)
+ ->and($product->variants->first()->is_default)->toBeTrue()
+ ->and($product->defaultVariant->id)->toBe($product->variants->first()->id);
+});
+
+test('variant titles combine option values in option order', function () {
+ $product = $this->service->create($this->store, [
+ 'title' => 'Titles',
+ 'options' => [
+ ['name' => 'Color', 'values' => ['Blue']],
+ ['name' => 'Size', 'values' => ['Medium']],
+ ],
+ ]);
+
+ expect($product->variants->first()->title())->toBe('Blue / Medium');
+});
diff --git a/tests/Pest.php b/tests/Pest.php
index 40d096b5..8099875b 100644
--- a/tests/Pest.php
+++ b/tests/Pest.php
@@ -45,3 +45,27 @@ function something()
{
// ..
}
+
+/**
+ * Create a minimal order with a single order line referencing the given
+ * product/variant. Orders are Phase 5, so rows are inserted directly.
+ */
+function createOrderLineFor(App\Models\Store $store, App\Models\Product $product, ?App\Models\ProductVariant $variant = null): void
+{
+ $orderId = Illuminate\Support\Facades\DB::table('orders')->insertGetId([
+ 'store_id' => $store->id,
+ 'order_number' => 'ORD-'.Illuminate\Support\Str::random(10),
+ 'payment_method' => 'credit_card',
+ ]);
+
+ Illuminate\Support\Facades\DB::table('order_lines')->insert([
+ 'order_id' => $orderId,
+ 'product_id' => $product->id,
+ 'variant_id' => $variant?->id,
+ 'title_snapshot' => $product->title,
+ 'sku_snapshot' => $variant?->sku,
+ 'quantity' => 1,
+ 'unit_price_amount' => $variant?->price_amount ?? 0,
+ 'total_amount' => $variant?->price_amount ?? 0,
+ ]);
+}
diff --git a/tests/Unit/HandleGeneratorTest.php b/tests/Unit/HandleGeneratorTest.php
new file mode 100644
index 00000000..21079748
--- /dev/null
+++ b/tests/Unit/HandleGeneratorTest.php
@@ -0,0 +1,49 @@
+createStore();
+
+ $handle = HandleGenerator::generate('My Cool Product!', 'products', $store->id);
+
+ expect($handle)->toBe('my-cool-product');
+});
+
+test('it appends an incrementing suffix on collisions', function () {
+ $store = $this->createStore();
+
+ Product::factory()->create(['store_id' => $store->id, 'handle' => 'my-product']);
+ Product::factory()->create(['store_id' => $store->id, 'handle' => 'my-product-1']);
+
+ $handle = HandleGenerator::generate('My Product', 'products', $store->id);
+
+ expect($handle)->toBe('my-product-2');
+});
+
+test('handles are unique per store, not globally', function () {
+ $storeA = $this->createStore();
+ $storeB = $this->createStore();
+
+ Product::factory()->create(['store_id' => $storeA->id, 'handle' => 'my-product']);
+
+ expect(HandleGenerator::generate('My Product', 'products', $storeB->id))->toBe('my-product');
+});
+
+test('excludeId ignores the given record, e.g. when updating itself', function () {
+ $store = $this->createStore();
+
+ $product = Product::factory()->create(['store_id' => $store->id, 'handle' => 'my-product']);
+
+ expect(HandleGenerator::generate('My Product', 'products', $store->id, $product->id))->toBe('my-product');
+});
+
+test('it falls back to a default slug when the title has no slug characters', function () {
+ $store = $this->createStore();
+
+ expect(HandleGenerator::generate('!!!', 'products', $store->id))->toBe('untitled');
+});
diff --git a/tests/Unit/SanitizeHtmlTest.php b/tests/Unit/SanitizeHtmlTest.php
new file mode 100644
index 00000000..55c957be
--- /dev/null
+++ b/tests/Unit/SanitizeHtmlTest.php
@@ -0,0 +1,103 @@
+Hello bold and italic
Title quote ';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->toContain('')
+ ->toContain('bold ')
+ ->toContain('italic ')
+ ->toContain('
')
+ ->toContain('Title ')
+ ->toContain('quote ');
+});
+
+test('script and style elements are removed with their content', function () {
+ $html = 'Safe
';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->not->toContain('script')
+ ->not->toContain('alert')
+ ->not->toContain('display:none')
+ ->toContain('Safe
');
+});
+
+test('disallowed elements are unwrapped but their text is kept', function () {
+ $html = '';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->not->toContain('iframe')
+ ->not->toContain('section')
+ ->toContain('Kept
');
+});
+
+test('event handlers and other attributes are stripped', function () {
+ $html = 'Text
link ';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->not->toContain('onclick')
+ ->not->toContain('class')
+ ->not->toContain('onmouseover')
+ ->not->toContain('target')
+ ->toContain('link ');
+});
+
+test('javascript: hrefs are neutralized', function () {
+ $html = 'click ';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->not->toContain('javascript:')
+ ->toContain('click ');
+});
+
+test('img src and alt attributes are kept', function () {
+ $html = ' ';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->toContain('src="https://example.com/pic.jpg"')
+ ->toContain('alt="A picture"')
+ ->not->toContain('width')
+ ->not->toContain('onerror');
+});
+
+test('empty elements are removed', function () {
+ $html = '
Real content
';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->not->toContain('')
+ ->toContain('Real content
')
+ ->not->toContain('
');
+});
+
+test('tables are preserved', function () {
+ $html = '';
+
+ $result = (new SanitizeHtml)($html);
+
+ expect($result)
+ ->toContain('')
+ ->toContain('Head ')
+ ->toContain('Cell ');
+});
+
+test('null and empty input pass through', function () {
+ expect((new SanitizeHtml)(null))->toBeNull()
+ ->and((new SanitizeHtml)(''))->toBe('')
+ ->and((new SanitizeHtml)(' '))->toBe(' ');
+});
From 34ac3644300b763f5eaafc2c9fcd3f28ccbecc86 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 12:56:19 +0200
Subject: [PATCH 05/29] 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
---
app/Livewire/Storefront/Collections/Index.php | 30 ++
app/Livewire/Storefront/Collections/Show.php | 233 ++++++++++++++
app/Livewire/Storefront/Home.php | 93 ++++++
app/Livewire/Storefront/Pages/Show.php | 37 +++
app/Livewire/Storefront/Products/Show.php | 227 +++++++++++++
app/Models/NavigationItem.php | 82 +++++
app/Models/NavigationMenu.php | 48 +++
app/Models/Page.php | 72 +++++
app/Models/Theme.php | 129 ++++++++
app/Models/ThemeFile.php | 43 +++
app/Models/ThemeSettings.php | 79 +++++
app/Providers/AppServiceProvider.php | 7 +-
app/Services/NavigationService.php | 171 ++++++++++
app/Services/ThemeSettingsService.php | 132 ++++++++
app/Support/Money.php | 19 ++
database/factories/NavigationItemFactory.php | 30 ++
database/factories/NavigationMenuFactory.php | 48 +++
database/factories/PageFactory.php | 41 +++
database/factories/ThemeFactory.php | 40 +++
database/factories/ThemeFileFactory.php | 30 ++
database/factories/ThemeSettingsFactory.php | 25 ++
resources/css/app.css | 42 +++
.../storefront/collections/index.blade.php | 32 ++
.../collections/partials/filters.blade.php | 70 +++++
.../storefront/collections/show.blade.php | 124 ++++++++
.../views/livewire/storefront/home.blade.php | 93 ++++++
.../livewire/storefront/pages/show.blade.php | 14 +
.../storefront/products/show.blade.php | 219 +++++++++++++
.../storefront/components/badge.blade.php | 25 ++
.../components/breadcrumbs.blade.php | 43 +++
.../components/pagination.blade.php | 74 +++++
.../storefront/components/price.blade.php | 25 ++
.../components/product-card.blade.php | 97 ++++++
.../components/quantity-selector.blade.php | 52 +++
.../views/storefront/layouts/app.blade.php | 297 ++++++++++++++++++
routes/web.php | 13 +-
specs/progress.md | 10 +-
tests/Feature/Storefront/BrowsingTest.php | 283 +++++++++++++++++
.../Feature/Storefront/ThemeSettingsTest.php | 93 ++++++
tests/Unit/MoneyTest.php | 24 ++
40 files changed, 3239 insertions(+), 7 deletions(-)
create mode 100644 app/Livewire/Storefront/Collections/Index.php
create mode 100644 app/Livewire/Storefront/Collections/Show.php
create mode 100644 app/Livewire/Storefront/Home.php
create mode 100644 app/Livewire/Storefront/Pages/Show.php
create mode 100644 app/Livewire/Storefront/Products/Show.php
create mode 100644 app/Models/NavigationItem.php
create mode 100644 app/Models/NavigationMenu.php
create mode 100644 app/Models/Page.php
create mode 100644 app/Models/Theme.php
create mode 100644 app/Models/ThemeFile.php
create mode 100644 app/Models/ThemeSettings.php
create mode 100644 app/Services/NavigationService.php
create mode 100644 app/Services/ThemeSettingsService.php
create mode 100644 app/Support/Money.php
create mode 100644 database/factories/NavigationItemFactory.php
create mode 100644 database/factories/NavigationMenuFactory.php
create mode 100644 database/factories/PageFactory.php
create mode 100644 database/factories/ThemeFactory.php
create mode 100644 database/factories/ThemeFileFactory.php
create mode 100644 database/factories/ThemeSettingsFactory.php
create mode 100644 resources/views/livewire/storefront/collections/index.blade.php
create mode 100644 resources/views/livewire/storefront/collections/partials/filters.blade.php
create mode 100644 resources/views/livewire/storefront/collections/show.blade.php
create mode 100644 resources/views/livewire/storefront/home.blade.php
create mode 100644 resources/views/livewire/storefront/pages/show.blade.php
create mode 100644 resources/views/livewire/storefront/products/show.blade.php
create mode 100644 resources/views/storefront/components/badge.blade.php
create mode 100644 resources/views/storefront/components/breadcrumbs.blade.php
create mode 100644 resources/views/storefront/components/pagination.blade.php
create mode 100644 resources/views/storefront/components/price.blade.php
create mode 100644 resources/views/storefront/components/product-card.blade.php
create mode 100644 resources/views/storefront/components/quantity-selector.blade.php
create mode 100644 resources/views/storefront/layouts/app.blade.php
create mode 100644 tests/Feature/Storefront/BrowsingTest.php
create mode 100644 tests/Feature/Storefront/ThemeSettingsTest.php
create mode 100644 tests/Unit/MoneyTest.php
diff --git a/app/Livewire/Storefront/Collections/Index.php b/app/Livewire/Storefront/Collections/Index.php
new file mode 100644
index 00000000..bb22234f
--- /dev/null
+++ b/app/Livewire/Storefront/Collections/Index.php
@@ -0,0 +1,30 @@
+where('status', CollectionStatus::Active)
+ ->latest()
+ ->get();
+
+ return view('livewire.storefront.collections.index', [
+ 'collections' => $collections,
+ ])
+ ->layout('storefront.layouts.app', [
+ 'metaDescription' => 'Browse all collections of '.app('current_store')->name,
+ ])
+ ->title('Collections - '.app('current_store')->name);
+ }
+}
diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php
new file mode 100644
index 00000000..0e40bcca
--- /dev/null
+++ b/app/Livewire/Storefront/Collections/Show.php
@@ -0,0 +1,233 @@
+
+ */
+ public const SORT_OPTIONS = [
+ 'featured' => 'Featured',
+ 'price-asc' => 'Price: Low to High',
+ 'price-desc' => 'Price: High to Low',
+ 'newest' => 'Newest',
+ 'best-selling' => 'Best Selling',
+ ];
+
+ public Collection $collection;
+
+ public string $sort = 'featured';
+
+ public bool $inStock = false;
+
+ public ?string $minPrice = null;
+
+ public ?string $maxPrice = null;
+
+ /** @var list */
+ public array $types = [];
+
+ /** @var list */
+ public array $vendors = [];
+
+ /**
+ * Resolve the collection by handle; only active collections are visible.
+ */
+ public function mount(string $handle): void
+ {
+ $this->collection = Collection::query()
+ ->where('handle', $handle)
+ ->where('status', CollectionStatus::Active)
+ ->firstOrFail();
+ }
+
+ /**
+ * Any filter or sort change returns to the first page.
+ */
+ public function updated(): void
+ {
+ $this->resetPage();
+ }
+
+ /**
+ * Reset every filter to its default.
+ */
+ public function clearFilters(): void
+ {
+ $this->reset(['inStock', 'minPrice', 'maxPrice', 'types', 'vendors']);
+ $this->resetPage();
+ }
+
+ /**
+ * Render the collection page.
+ */
+ public function render(): View
+ {
+ $products = $this->collection->products()
+ ->visible()
+ ->with(['variants.inventoryItem', 'media']);
+
+ $this->applyFilters($products);
+ $this->applySort($products);
+
+ $paginator = $products->paginate(self::PER_PAGE);
+
+ $storeName = app('current_store')->name;
+
+ return view('livewire.storefront.collections.show', [
+ 'products' => $paginator,
+ 'sortOptions' => self::SORT_OPTIONS,
+ 'availableTypes' => $this->availableFilterValues('product_type'),
+ 'availableVendors' => $this->availableFilterValues('vendor'),
+ 'activeFilters' => $this->activeFilters(),
+ ])
+ ->layout('storefront.layouts.app', [
+ 'metaDescription' => str()->limit(trim(strip_tags($this->collection->description_html ?? '')), 160, ''),
+ ])
+ ->title("{$this->collection->title} - {$storeName}");
+ }
+
+ /**
+ * Apply the active filters to the product query.
+ *
+ * @param BelongsToMany<\App\Models\Product> $query
+ */
+ private function applyFilters(BelongsToMany $query): void
+ {
+ if ($this->inStock) {
+ $query->whereHas('variants.inventoryItem', function ($q): void {
+ $q->whereRaw('(quantity_on_hand - quantity_reserved) > 0');
+ });
+ }
+
+ $minCents = $this->priceToCents($this->minPrice);
+ if ($minCents !== null) {
+ $query->whereHas('variants', fn ($q) => $q->where('price_amount', '>=', $minCents));
+ }
+
+ $maxCents = $this->priceToCents($this->maxPrice);
+ if ($maxCents !== null) {
+ $query->whereHas('variants', fn ($q) => $q->where('price_amount', '<=', $maxCents));
+ }
+
+ if ($this->types !== []) {
+ $query->whereIn('products.product_type', $this->types);
+ }
+
+ if ($this->vendors !== []) {
+ $query->whereIn('products.vendor', $this->vendors);
+ }
+ }
+
+ /**
+ * Apply the selected sort order to the product query.
+ *
+ * @param BelongsToMany<\App\Models\Product> $query
+ */
+ private function applySort(BelongsToMany $query): void
+ {
+ match ($this->sort) {
+ 'price-asc' => $query->orderBy($this->minimumPriceSubquery()),
+ 'price-desc' => $query->orderByDesc($this->minimumPriceSubquery()),
+ 'newest' => $query->orderByDesc('products.created_at'),
+ 'best-selling' => $query->orderByDesc($this->salesCountSubquery()),
+ default => $query->orderBy('collection_products.position'),
+ };
+ }
+
+ /**
+ * Subquery selecting the minimum variant price of a product.
+ */
+ private function minimumPriceSubquery(): BuilderContract
+ {
+ return ProductVariant::query()
+ ->selectRaw('MIN(price_amount)')
+ ->whereColumn('product_variants.product_id', 'products.id');
+ }
+
+ /**
+ * Subquery selecting the total sold quantity of a product.
+ */
+ private function salesCountSubquery(): \Illuminate\Database\Query\Builder
+ {
+ return DB::table('order_lines')
+ ->selectRaw('COALESCE(SUM(quantity), 0)')
+ ->whereColumn('order_lines.product_id', 'products.id');
+ }
+
+ /**
+ * Distinct non-null values of a product attribute within this collection.
+ *
+ * @return list
+ */
+ private function availableFilterValues(string $column): array
+ {
+ return $this->collection->products()
+ ->visible()
+ ->whereNotNull("products.{$column}")
+ ->distinct()
+ ->reorder("products.{$column}")
+ ->pluck("products.{$column}")
+ ->all();
+ }
+
+ /**
+ * Human-readable list of the currently active filters.
+ *
+ * @return list
+ */
+ private function activeFilters(): array
+ {
+ $active = [];
+
+ if ($this->inStock) {
+ $active[] = 'In stock';
+ }
+ if ($this->priceToCents($this->minPrice) !== null) {
+ $active[] = "Min {$this->minPrice}";
+ }
+ if ($this->priceToCents($this->maxPrice) !== null) {
+ $active[] = "Max {$this->maxPrice}";
+ }
+ foreach ($this->types as $type) {
+ $active[] = "Type: {$type}";
+ }
+ foreach ($this->vendors as $vendor) {
+ $active[] = "Vendor: {$vendor}";
+ }
+
+ return $active;
+ }
+
+ /**
+ * Convert a user-entered major-unit price into cents.
+ */
+ private function priceToCents(?string $price): ?int
+ {
+ if ($price === null || trim($price) === '' || ! is_numeric($price)) {
+ return null;
+ }
+
+ return max(0, (int) round(((float) $price) * 100));
+ }
+}
diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php
new file mode 100644
index 00000000..20746d90
--- /dev/null
+++ b/app/Livewire/Storefront/Home.php
@@ -0,0 +1,93 @@
+get('sections_order', []))
+ ->filter(fn (string $section): bool => (bool) $settings->get("{$section}.enabled", false))
+ ->values();
+
+ return view('livewire.storefront.home', [
+ 'sections' => $sections,
+ 'hero' => $settings->get('hero', []),
+ 'featuredCollections' => $sections->contains('featured_collections')
+ ? $this->featuredCollections($settings)
+ : collect(),
+ 'featuredProducts' => $sections->contains('featured_products')
+ ? $this->featuredProducts($settings)
+ : collect(),
+ 'richTextHtml' => $sections->contains('rich_text') ? $settings->get('rich_text.html') : null,
+ ])
+ ->layout('storefront.layouts.app', [
+ 'metaDescription' => $settings->get('seo.description'),
+ ])
+ ->title(app('current_store')->name);
+ }
+
+ /**
+ * Collections picked in theme settings, falling back to the newest ones.
+ *
+ * @return SupportCollection
+ */
+ private function featuredCollections(ThemeSettingsService $settings): SupportCollection
+ {
+ $handles = array_filter($settings->get('featured_collections.collection_handles', []));
+ $count = max(1, (int) $settings->get('featured_collections.count', 3));
+
+ $query = Collection::query()->where('status', CollectionStatus::Active);
+
+ if ($handles !== []) {
+ return $query->whereIn('handle', $handles)->limit($count)->get();
+ }
+
+ return $query->latest()->limit($count)->get();
+ }
+
+ /**
+ * Products from the configured collection, falling back to the newest
+ * visible products of the store.
+ *
+ * @return SupportCollection
+ */
+ private function featuredProducts(ThemeSettingsService $settings): SupportCollection
+ {
+ $count = max(1, (int) $settings->get('featured_products.count', 8));
+ $collectionHandle = $settings->get('featured_products.collection_handle');
+
+ if (is_string($collectionHandle) && $collectionHandle !== '') {
+ $collection = Collection::query()
+ ->where('handle', $collectionHandle)
+ ->where('status', CollectionStatus::Active)
+ ->first();
+
+ if ($collection !== null) {
+ return $collection->products()
+ ->visible()
+ ->with(['variants.inventoryItem', 'media'])
+ ->limit($count)
+ ->get();
+ }
+ }
+
+ return Product::query()
+ ->visible()
+ ->with(['variants.inventoryItem', 'media'])
+ ->latest()
+ ->limit($count)
+ ->get();
+ }
+}
diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php
new file mode 100644
index 00000000..adf4cb3f
--- /dev/null
+++ b/app/Livewire/Storefront/Pages/Show.php
@@ -0,0 +1,37 @@
+page = Page::query()
+ ->published()
+ ->where('handle', $handle)
+ ->firstOrFail();
+ }
+
+ /**
+ * Render the content page.
+ */
+ public function render(): View
+ {
+ $storeName = app('current_store')->name;
+
+ return view('livewire.storefront.pages.show')
+ ->layout('storefront.layouts.app', [
+ 'metaDescription' => str()->limit(trim(strip_tags($this->page->body_html ?? '')), 160, ''),
+ ])
+ ->title("{$this->page->title} - {$storeName}");
+ }
+}
diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php
new file mode 100644
index 00000000..12744f36
--- /dev/null
+++ b/app/Livewire/Storefront/Products/Show.php
@@ -0,0 +1,227 @@
+
+ */
+ public array $selectedOptions = [];
+
+ public int $quantity = 1;
+
+ /**
+ * Resolve the product by handle; only visible products are reachable.
+ */
+ public function mount(string $handle): void
+ {
+ $this->product = Product::query()
+ ->visible()
+ ->where('handle', $handle)
+ ->with(['media', 'options.values', 'variants.optionValues', 'variants.inventoryItem', 'collections'])
+ ->firstOrFail();
+
+ // Preselect the default variant's option values.
+ $default = $this->product->variants->firstWhere('is_default', true) ?? $this->product->variants->first();
+
+ foreach ($this->product->options as $option) {
+ $this->selectedOptions[$option->name] = $default?->optionValues
+ ->firstWhere('product_option_id', $option->id)?->value;
+ }
+ }
+
+ /**
+ * The variant matching all currently selected options, if any.
+ */
+ #[Computed]
+ public function selectedVariant(): ?ProductVariant
+ {
+ return $this->product->variants->first(function (ProductVariant $variant): bool {
+ foreach ($this->product->options as $option) {
+ $selected = $this->selectedOptions[$option->name] ?? null;
+ $variantValue = $variant->optionValues->firstWhere('product_option_id', $option->id)?->value;
+
+ if ($selected !== $variantValue) {
+ return false;
+ }
+ }
+
+ return true;
+ });
+ }
+
+ /**
+ * Whether a value of the given option leads to a purchasable variant
+ * given the other current selections.
+ */
+ public function isValueAvailable(ProductOption $option, string $value): bool
+ {
+ return $this->product->variants->contains(function (ProductVariant $variant) use ($option, $value): bool {
+ $matches = $variant->optionValues->firstWhere('product_option_id', $option->id)?->value === $value;
+
+ if (! $matches) {
+ return false;
+ }
+
+ foreach ($this->product->options as $otherOption) {
+ if ($otherOption->id === $option->id) {
+ continue;
+ }
+
+ $selected = $this->selectedOptions[$otherOption->name] ?? null;
+
+ if ($selected !== null && $variant->optionValues->firstWhere('product_option_id', $otherOption->id)?->value !== $selected) {
+ return false;
+ }
+ }
+
+ return $variant->isInStock() || $variant->isBackorderable();
+ });
+ }
+
+ /**
+ * Stock state of the selected variant for messaging and purchase rules.
+ *
+ * @return array{state: string, message: string, purchasable: bool, max: int|null}
+ */
+ public function stockState(): array
+ {
+ $variant = $this->selectedVariant();
+
+ if ($variant === null) {
+ return ['state' => 'unavailable', 'message' => 'Unavailable', 'purchasable' => false, 'max' => null];
+ }
+
+ $available = $variant->availableQuantity();
+
+ if ($available > 10) {
+ return ['state' => 'in_stock', 'message' => 'In stock', 'purchasable' => true, 'max' => $this->maxQuantity($variant, $available)];
+ }
+
+ if ($available > 0) {
+ return ['state' => 'low_stock', 'message' => "Only {$available} left in stock", 'purchasable' => true, 'max' => $this->maxQuantity($variant, $available)];
+ }
+
+ if ($variant->inventoryItem?->policy === InventoryPolicy::Continue) {
+ return ['state' => 'backorder', 'message' => 'Available on backorder', 'purchasable' => true, 'max' => null];
+ }
+
+ return ['state' => 'out_of_stock', 'message' => 'Out of stock', 'purchasable' => false, 'max' => null];
+ }
+
+ /**
+ * Select an option value and keep the quantity within bounds.
+ */
+ public function selectOption(string $optionName, string $value): void
+ {
+ $this->selectedOptions[$optionName] = $value;
+ $this->clampQuantity();
+ }
+
+ /**
+ * Stage the add-to-cart for the selected variant. The real cart is
+ * wired in Phase 4; for now we dispatch the browser event it will
+ * listen to.
+ */
+ public function addToCart(): void
+ {
+ $variant = $this->selectedVariant();
+
+ if ($variant === null || (! $variant->isInStock() && ! $variant->isBackorderable())) {
+ return;
+ }
+
+ $this->dispatch('add-to-cart', variantId: $variant->id, quantity: max(1, $this->quantity));
+ }
+
+ /**
+ * Render the product page.
+ */
+ public function render(): View
+ {
+ $variant = $this->selectedVariant();
+ $storeName = app('current_store')->name;
+ $currency = $variant?->currency ?? $this->product->variants->first()?->currency
+ ?? app('current_store')->default_currency;
+ $primaryImage = $this->product->media->first();
+ $metaDescription = str()->limit(trim(strip_tags($this->product->description_html ?? '')), 160, '');
+
+ return view('livewire.storefront.products.show', [
+ 'currency' => $currency,
+ 'stock' => $this->stockState(),
+ 'jsonLd' => $this->jsonLd($variant, $primaryImage?->url()),
+ ])
+ ->layout('storefront.layouts.app', [
+ 'metaDescription' => $metaDescription,
+ 'og' => array_filter([
+ 'title' => $this->product->title,
+ 'description' => $metaDescription,
+ 'image' => $primaryImage?->url(),
+ 'type' => 'product',
+ 'price_amount' => $variant !== null ? number_format($variant->price_amount / 100, 2, '.', '') : null,
+ 'price_currency' => $currency,
+ ]),
+ ])
+ ->title("{$this->product->title} - {$storeName}");
+ }
+
+ /**
+ * JSON-LD structured data for the product (spec 04 §19).
+ *
+ * @return array
+ */
+ private function jsonLd(?ProductVariant $variant, ?string $imageUrl): array
+ {
+ return array_filter([
+ '@context' => 'https://schema.org',
+ '@type' => 'Product',
+ 'name' => $this->product->title,
+ 'description' => trim(strip_tags($this->product->description_html ?? '')),
+ 'image' => $imageUrl,
+ 'brand' => $this->product->vendor !== null ? ['@type' => 'Brand', 'name' => $this->product->vendor] : null,
+ 'offers' => $variant !== null ? [
+ '@type' => 'Offer',
+ 'price' => number_format($variant->price_amount / 100, 2, '.', ''),
+ 'priceCurrency' => $variant->currency,
+ 'availability' => $variant->isInStock() || $variant->isBackorderable()
+ ? 'https://schema.org/InStock'
+ : 'https://schema.org/OutOfStock',
+ ] : null,
+ ]);
+ }
+
+ /**
+ * Maximum selectable quantity for the variant.
+ */
+ private function maxQuantity(ProductVariant $variant, int $available): ?int
+ {
+ return $variant->inventoryItem?->policy === InventoryPolicy::Deny ? $available : null;
+ }
+
+ /**
+ * Keep the quantity within the bounds of the selected variant.
+ */
+ private function clampQuantity(): void
+ {
+ $max = $this->stockState()['max'];
+
+ if ($max !== null) {
+ $this->quantity = min($this->quantity, $max);
+ }
+
+ $this->quantity = max(1, $this->quantity);
+ }
+}
diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php
new file mode 100644
index 00000000..a8f9c9ae
--- /dev/null
+++ b/app/Models/NavigationItem.php
@@ -0,0 +1,82 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'menu_id',
+ 'type',
+ 'label',
+ 'url',
+ 'resource_id',
+ 'position',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => NavigationItemType::class,
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Keep the cached navigation tree of the parent menu in sync.
+ $flush = fn (NavigationItem $item) => $item->flushMenuCache();
+
+ static::saved($flush);
+ static::deleted($flush);
+ }
+
+ /**
+ * Get the menu the item belongs to.
+ *
+ * @return BelongsTo
+ */
+ public function menu(): BelongsTo
+ {
+ return $this->belongsTo(NavigationMenu::class, 'menu_id');
+ }
+
+ /**
+ * Invalidate the cached tree of the parent menu.
+ */
+ protected function flushMenuCache(): void
+ {
+ $menu = $this->relationLoaded('menu') ? $this->menu : $this->menu()->first();
+
+ if ($menu !== null) {
+ app(NavigationService::class)->invalidate($menu->store_id, $menu->handle);
+ }
+ }
+}
diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php
new file mode 100644
index 00000000..70a7600b
--- /dev/null
+++ b/app/Models/NavigationMenu.php
@@ -0,0 +1,48 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'handle',
+ 'title',
+ ];
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Keep the cached navigation tree of this menu in sync.
+ $flush = fn (NavigationMenu $menu) => app(NavigationService::class)->invalidate($menu->store_id, $menu->handle);
+
+ static::saved($flush);
+ static::deleted($flush);
+ }
+
+ /**
+ * Get the items of the menu in display order.
+ *
+ * @return HasMany
+ */
+ public function items(): HasMany
+ {
+ return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position');
+ }
+}
diff --git a/app/Models/Page.php b/app/Models/Page.php
new file mode 100644
index 00000000..bdc018f2
--- /dev/null
+++ b/app/Models/Page.php
@@ -0,0 +1,72 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'title',
+ 'handle',
+ 'body_html',
+ 'status',
+ 'published_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => PageStatus::class,
+ 'published_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Default the handle from the title, unique per store.
+ static::creating(function (Page $page): void {
+ if (blank($page->handle)) {
+ $page->handle = HandleGenerator::generate($page->title, 'pages', $page->store_id);
+ }
+ });
+
+ // Sanitize rich-text content against the HTML allowlist.
+ static::saving(function (Page $page): void {
+ $page->body_html = app(SanitizeHtml::class)($page->body_html);
+ });
+ }
+
+ /**
+ * Scope to pages visible on the storefront.
+ *
+ * @param Builder $query
+ */
+ protected function scopePublished(Builder $query): void
+ {
+ $query->where('status', PageStatus::Published);
+ }
+}
diff --git a/app/Models/Theme.php b/app/Models/Theme.php
new file mode 100644
index 00000000..bd1c30f3
--- /dev/null
+++ b/app/Models/Theme.php
@@ -0,0 +1,129 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'name',
+ 'version',
+ 'status',
+ 'published_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => ThemeStatus::class,
+ 'published_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ // Theme changes (including publish/unpublish) affect the cached
+ // storefront settings of the owning store.
+ $flush = fn (Theme $theme) => app(ThemeSettingsService::class)->invalidate($theme->store_id);
+
+ static::saved($flush);
+ static::deleted($flush);
+ }
+
+ /**
+ * Get the files belonging to the theme.
+ *
+ * @return HasMany
+ */
+ public function files(): HasMany
+ {
+ return $this->hasMany(ThemeFile::class);
+ }
+
+ /**
+ * Get the settings record of the theme.
+ *
+ * @return HasOne
+ */
+ public function settings(): HasOne
+ {
+ return $this->hasOne(ThemeSettings::class);
+ }
+
+ /**
+ * Whether the theme is the published one for its store.
+ */
+ public function isPublished(): bool
+ {
+ return $this->status === ThemeStatus::Published;
+ }
+
+ /**
+ * Publish this theme and demote every other theme of the store to draft.
+ */
+ public function publish(): void
+ {
+ DB::transaction(function (): void {
+ static::withoutGlobalScopes()
+ ->where('store_id', $this->store_id)
+ ->whereKeyNot($this->getKey())
+ ->where('status', ThemeStatus::Published->value)
+ ->update(['status' => ThemeStatus::Draft->value]);
+
+ $this->forceFill([
+ 'status' => ThemeStatus::Published,
+ 'published_at' => now(),
+ ])->save();
+ });
+ }
+
+ /**
+ * Create a draft copy of the theme including files and settings.
+ */
+ public function duplicate(string $name): Theme
+ {
+ return DB::transaction(function () use ($name): Theme {
+ $copy = $this->replicate(['status', 'published_at'])->forceFill([
+ 'name' => $name,
+ 'status' => ThemeStatus::Draft,
+ 'published_at' => null,
+ ]);
+ $copy->save();
+
+ foreach ($this->files as $file) {
+ $copy->files()->create($file->only(['path', 'storage_key', 'sha256', 'byte_size']));
+ }
+
+ if ($this->settings !== null) {
+ $copy->settings()->create(['settings_json' => $this->settings->settings_json]);
+ }
+
+ return $copy;
+ });
+ }
+}
diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php
new file mode 100644
index 00000000..d56a910d
--- /dev/null
+++ b/app/Models/ThemeFile.php
@@ -0,0 +1,43 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'theme_id',
+ 'path',
+ 'storage_key',
+ 'sha256',
+ 'byte_size',
+ ];
+
+ /**
+ * Get the theme that owns the file.
+ *
+ * @return BelongsTo
+ */
+ public function theme(): BelongsTo
+ {
+ return $this->belongsTo(Theme::class);
+ }
+}
diff --git a/app/Models/ThemeSettings.php b/app/Models/ThemeSettings.php
new file mode 100644
index 00000000..3ccd5ee6
--- /dev/null
+++ b/app/Models/ThemeSettings.php
@@ -0,0 +1,79 @@
+ */
+ use HasFactory;
+
+ /**
+ * The primary key is the owning theme's id (one-to-one).
+ *
+ * @var string
+ */
+ protected $primaryKey = 'theme_id';
+
+ /**
+ * The primary key is not auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The table only has an updated_at column.
+ *
+ * @var string|null
+ */
+ const CREATED_AT = null;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'theme_id',
+ 'settings_json',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'settings_json' => 'array',
+ ];
+ }
+
+ /**
+ * Register model event hooks.
+ */
+ protected static function booted(): void
+ {
+ $flush = fn (ThemeSettings $settings) => app(ThemeSettingsService::class)
+ ->invalidate($settings->theme?->store_id ?? Theme::withoutGlobalScopes()->whereKey($settings->theme_id)->value('store_id'));
+
+ static::saved($flush);
+ static::deleted($flush);
+ }
+
+ /**
+ * Get the theme these settings belong to.
+ *
+ * @return BelongsTo
+ */
+ public function theme(): BelongsTo
+ {
+ return $this->belongsTo(Theme::class);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 960131cf..6fb8481f 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -5,11 +5,13 @@
use App\Auth\CustomerUserProvider;
use App\Enums\StoreUserRole;
use App\Models\User;
+use App\Services\ThemeSettingsService;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
+use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
@@ -24,7 +26,7 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
- //
+ $this->app->singleton(ThemeSettingsService::class);
}
/**
@@ -36,6 +38,9 @@ public function boot(): void
$this->configureAuth();
$this->configureGates();
$this->configureRateLimiting();
+
+ // Anonymous storefront components:
+ Blade::anonymousComponentPath(resource_path('views/storefront/components'), 'storefront');
}
/**
diff --git a/app/Services/NavigationService.php b/app/Services/NavigationService.php
new file mode 100644
index 00000000..a6e47b8c
--- /dev/null
+++ b/app/Services/NavigationService.php
@@ -0,0 +1,171 @@
+
+ */
+ public function forHandle(string $handle): array
+ {
+ if (! app()->bound('current_store')) {
+ return [];
+ }
+
+ $storeId = (int) app('current_store')->getKey();
+
+ return Cache::remember(
+ $this->cacheKey($storeId, $handle),
+ self::TTL_SECONDS,
+ function () use ($handle): array {
+ $menu = NavigationMenu::query()->where('handle', $handle)->first();
+
+ return $menu === null ? [] : $this->buildTree($menu);
+ },
+ );
+ }
+
+ /**
+ * Build the navigation tree for a menu: a flat list of resolved items
+ * ordered by position (nesting is not modelled in the schema, so every
+ * item carries an empty `children` list for forward compatibility).
+ *
+ * @return array
+ */
+ public function buildTree(NavigationMenu $menu): array
+ {
+ $items = $menu->items;
+ $handles = $this->prefetchResourceHandles($items);
+
+ return $items->map(fn (NavigationItem $item): array => [
+ 'id' => $item->id,
+ 'label' => $item->label,
+ 'url' => $this->resolveUrl($item, $handles),
+ 'type' => $item->type,
+ 'children' => [],
+ ])->all();
+ }
+
+ /**
+ * Resolve the storefront URL of a navigation item based on its type.
+ *
+ * @param array>|null $handles Prefetched resource handles keyed by type, then resource id.
+ */
+ public function resolveUrl(NavigationItem $item, ?array $handles = null): string
+ {
+ if ($item->type === NavigationItemType::Link) {
+ return $item->url ?? '#';
+ }
+
+ $handle = $handles[$item->type->value][$item->resource_id] ?? $this->lookupResourceHandle($item);
+
+ if ($handle === null) {
+ return '#';
+ }
+
+ return match ($item->type) {
+ NavigationItemType::Page => "/pages/{$handle}",
+ NavigationItemType::Collection => "/collections/{$handle}",
+ NavigationItemType::Product => "/products/{$handle}",
+ default => '#',
+ };
+ }
+
+ /**
+ * Forget the cached tree of a store's menu.
+ */
+ public function invalidate(?int $storeId, string $menuHandle): void
+ {
+ if ($storeId === null) {
+ return;
+ }
+
+ Cache::forget($this->cacheKey($storeId, $menuHandle));
+ }
+
+ /**
+ * Look up the handle of the resource a page/collection/product item
+ * points to. Returns null when the resource no longer exists.
+ */
+ private function lookupResourceHandle(NavigationItem $item): ?string
+ {
+ if ($item->resource_id === null) {
+ return null;
+ }
+
+ $model = match ($item->type) {
+ NavigationItemType::Page => Page::class,
+ NavigationItemType::Collection => Collection::class,
+ NavigationItemType::Product => Product::class,
+ default => null,
+ };
+
+ if ($model === null) {
+ return null;
+ }
+
+ return $model::query()->whereKey($item->resource_id)->value('handle');
+ }
+
+ /**
+ * Bulk-load the handles of all resources referenced by the items to
+ * avoid one query per item.
+ *
+ * @param \Illuminate\Database\Eloquent\Collection $items
+ * @return array>
+ */
+ private function prefetchResourceHandles(\Illuminate\Database\Eloquent\Collection $items): array
+ {
+ $handles = [];
+
+ $resourceItems = $items
+ ->filter(fn (NavigationItem $item): bool => $item->type !== NavigationItemType::Link && $item->resource_id !== null)
+ ->groupBy(fn (NavigationItem $item): string => $item->type->value);
+
+ foreach ($resourceItems as $type => $group) {
+ $model = match ($type) {
+ 'page' => Page::class,
+ 'collection' => Collection::class,
+ 'product' => Product::class,
+ default => null,
+ };
+
+ if ($model === null) {
+ continue;
+ }
+
+ $handles[$type] = $model::query()
+ ->whereIn('id', $group->pluck('resource_id')->all())
+ ->pluck('handle', 'id')
+ ->all();
+ }
+
+ return $handles;
+ }
+
+ /**
+ * Cache key for a store's menu tree.
+ */
+ private function cacheKey(int $storeId, string $menuHandle): string
+ {
+ return "nav:{$storeId}:{$menuHandle}";
+ }
+}
diff --git a/app/Services/ThemeSettingsService.php b/app/Services/ThemeSettingsService.php
new file mode 100644
index 00000000..76955a5a
--- /dev/null
+++ b/app/Services/ThemeSettingsService.php
@@ -0,0 +1,132 @@
+
+ */
+ public const DEFAULTS = [
+ 'announcement' => [
+ 'enabled' => false,
+ 'text' => '',
+ 'link' => null,
+ ],
+ 'header' => [
+ 'sticky' => false,
+ 'logo_url' => null,
+ ],
+ 'colors' => [
+ 'primary' => '#2563eb',
+ 'secondary' => '#64748b',
+ 'accent' => '#f59e0b',
+ ],
+ 'dark_mode' => 'system',
+ 'sections_order' => ['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text'],
+ 'hero' => [
+ 'enabled' => true,
+ 'heading' => null,
+ 'subheading' => null,
+ 'cta_label' => 'Shop now',
+ 'cta_url' => '/collections',
+ 'image_url' => null,
+ ],
+ 'featured_collections' => [
+ 'enabled' => true,
+ 'count' => 3,
+ 'collection_handles' => [],
+ ],
+ 'featured_products' => [
+ 'enabled' => true,
+ 'count' => 8,
+ 'collection_handle' => null,
+ ],
+ 'newsletter' => [
+ 'enabled' => true,
+ ],
+ 'rich_text' => [
+ 'enabled' => false,
+ 'html' => null,
+ ],
+ 'footer' => [
+ 'about' => null,
+ 'social' => [],
+ ],
+ 'seo' => [
+ 'description' => null,
+ ],
+ ];
+
+ /**
+ * All resolved settings for the current store: the published theme's
+ * settings merged onto the defaults, cached for 5 minutes per store.
+ *
+ * @return array
+ */
+ public function all(): array
+ {
+ if (! app()->bound('current_store')) {
+ return self::DEFAULTS;
+ }
+
+ $storeId = (int) app('current_store')->getKey();
+
+ return Cache::remember(
+ "theme_settings:{$storeId}",
+ self::TTL_SECONDS,
+ fn (): array => $this->loadSettings($storeId),
+ );
+ }
+
+ /**
+ * Get a single setting by dot notation.
+ */
+ public function get(string $key, mixed $default = null): mixed
+ {
+ return Arr::get($this->all(), $key, $default);
+ }
+
+ /**
+ * Forget the cached settings of a store.
+ */
+ public function invalidate(?int $storeId): void
+ {
+ if ($storeId === null) {
+ return;
+ }
+
+ Cache::forget("theme_settings:{$storeId}");
+ }
+
+ /**
+ * Load the published theme's settings for the store merged onto defaults.
+ *
+ * @return array
+ */
+ private function loadSettings(int $storeId): array
+ {
+ $theme = Theme::withoutGlobalScopes()
+ ->where('store_id', $storeId)
+ ->where('status', ThemeStatus::Published)
+ ->latest('published_at')
+ ->first();
+
+ $settings = $theme?->settings?->settings_json ?? [];
+
+ return array_replace_recursive(self::DEFAULTS, $settings);
+ }
+}
diff --git a/app/Support/Money.php b/app/Support/Money.php
new file mode 100644
index 00000000..87dbfdcf
--- /dev/null
+++ b/app/Support/Money.php
@@ -0,0 +1,19 @@
+ "24.99 EUR".
+ */
+ public static function format(int $cents, string $currency): string
+ {
+ return number_format($cents / 100, 2, '.', ',').' '.strtoupper($currency);
+ }
+}
diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php
new file mode 100644
index 00000000..77c2ec6e
--- /dev/null
+++ b/database/factories/NavigationItemFactory.php
@@ -0,0 +1,30 @@
+
+ */
+class NavigationItemFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'menu_id' => NavigationMenu::factory(),
+ 'type' => NavigationItemType::Link,
+ 'label' => fake()->words(2, true),
+ 'url' => '/'.fake()->slug(2),
+ 'resource_id' => null,
+ 'position' => 0,
+ ];
+ }
+}
diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php
new file mode 100644
index 00000000..ab56aa81
--- /dev/null
+++ b/database/factories/NavigationMenuFactory.php
@@ -0,0 +1,48 @@
+
+ */
+class NavigationMenuFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'handle' => fake()->unique()->slug(2),
+ 'title' => fake()->words(2, true),
+ ];
+ }
+
+ /**
+ * Indicate that the menu is the storefront main menu.
+ */
+ public function mainMenu(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'handle' => 'main-menu',
+ 'title' => 'Main menu',
+ ]);
+ }
+
+ /**
+ * Indicate that the menu is the storefront footer menu.
+ */
+ public function footerMenu(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'handle' => 'footer-menu',
+ 'title' => 'Footer menu',
+ ]);
+ }
+}
diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php
new file mode 100644
index 00000000..8e49b40b
--- /dev/null
+++ b/database/factories/PageFactory.php
@@ -0,0 +1,41 @@
+
+ */
+class PageFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'title' => fake()->unique()->words(3, true),
+ 'handle' => fake()->unique()->slug(2),
+ 'body_html' => ''.fake()->sentence().'
',
+ 'status' => PageStatus::Draft,
+ 'published_at' => null,
+ ];
+ }
+
+ /**
+ * Indicate that the page is published.
+ */
+ public function published(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => PageStatus::Published,
+ 'published_at' => now(),
+ ]);
+ }
+}
diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php
new file mode 100644
index 00000000..1eb1738e
--- /dev/null
+++ b/database/factories/ThemeFactory.php
@@ -0,0 +1,40 @@
+
+ */
+class ThemeFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'name' => fake()->words(2, true),
+ 'version' => '1.0.0',
+ 'status' => ThemeStatus::Draft,
+ 'published_at' => null,
+ ];
+ }
+
+ /**
+ * Indicate that the theme is published.
+ */
+ public function published(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => ThemeStatus::Published,
+ 'published_at' => now(),
+ ]);
+ }
+}
diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php
new file mode 100644
index 00000000..674ce2bc
--- /dev/null
+++ b/database/factories/ThemeFileFactory.php
@@ -0,0 +1,30 @@
+
+ */
+class ThemeFileFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ $path = 'templates/'.fake()->unique()->slug(1).'.blade.php';
+
+ return [
+ 'theme_id' => Theme::factory(),
+ 'path' => $path,
+ 'storage_key' => 'themes/'.fake()->uuid().'/'.$path,
+ 'sha256' => hash('sha256', fake()->sentence()),
+ 'byte_size' => fake()->numberBetween(100, 50000),
+ ];
+ }
+}
diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php
new file mode 100644
index 00000000..d81f805b
--- /dev/null
+++ b/database/factories/ThemeSettingsFactory.php
@@ -0,0 +1,25 @@
+
+ */
+class ThemeSettingsFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'theme_id' => Theme::factory(),
+ 'settings_json' => [],
+ ];
+ }
+}
diff --git a/resources/css/app.css b/resources/css/app.css
index ad6eeedc..6606ddbe 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -64,3 +64,45 @@ select:focus[data-flux-control] {
/* \[:where(&)\]:size-4 {
@apply size-4;
} */
+
+[x-cloak] {
+ display: none !important;
+}
+
+/* Lightweight rich-text styling for sanitized CMS/theme HTML. */
+.storefront-prose > * + * {
+ margin-top: 1rem;
+}
+
+.storefront-prose h1,
+.storefront-prose h2,
+.storefront-prose h3,
+.storefront-prose h4 {
+ font-weight: 700;
+ line-height: 1.25;
+}
+
+.storefront-prose h1 { font-size: 1.875rem; }
+.storefront-prose h2 { font-size: 1.5rem; }
+.storefront-prose h3 { font-size: 1.25rem; }
+
+.storefront-prose ul {
+ list-style: disc;
+ padding-left: 1.5rem;
+}
+
+.storefront-prose ol {
+ list-style: decimal;
+ padding-left: 1.5rem;
+}
+
+.storefront-prose a {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.storefront-prose blockquote {
+ border-left: 4px solid currentColor;
+ padding-left: 1rem;
+ opacity: 0.85;
+}
diff --git a/resources/views/livewire/storefront/collections/index.blade.php b/resources/views/livewire/storefront/collections/index.blade.php
new file mode 100644
index 00000000..1b8263f5
--- /dev/null
+++ b/resources/views/livewire/storefront/collections/index.blade.php
@@ -0,0 +1,32 @@
+
+
+
+
Collections
+
+ @if ($collections->isEmpty())
+
+
+
+
+
No collections yet
+
Check back soon for new collections.
+
+ @else
+
+ @endif
+
diff --git a/resources/views/livewire/storefront/collections/partials/filters.blade.php b/resources/views/livewire/storefront/collections/partials/filters.blade.php
new file mode 100644
index 00000000..55b3767e
--- /dev/null
+++ b/resources/views/livewire/storefront/collections/partials/filters.blade.php
@@ -0,0 +1,70 @@
+{{-- Filter groups shared between the desktop sidebar and the mobile drawer. --}}
+
diff --git a/resources/views/livewire/storefront/collections/show.blade.php b/resources/views/livewire/storefront/collections/show.blade.php
new file mode 100644
index 00000000..5da89aa1
--- /dev/null
+++ b/resources/views/livewire/storefront/collections/show.blade.php
@@ -0,0 +1,124 @@
+
+
+
+ {{-- Collection header --}}
+
+
{{ $collection->title }}
+ @if (! empty($collection->description_html))
+
+ {!! $collection->description_html !!}
+
+ @endif
+
+
+ {{-- Toolbar --}}
+
+
+
+
+
+ Filters
+
+
{{ $products->total() }} {{ str('product')->plural($products->total()) }}
+
+ Sort by
+
+ @foreach ($sortOptions as $value => $label)
+ {{ $label }}
+ @endforeach
+
+
+
+
+ {{-- Active filter pills --}}
+ @if (count($activeFilters) > 0)
+
+ @foreach ($activeFilters as $filter)
+
+ {{ $filter }}
+
+ @endforeach
+
+ Clear all
+
+
+ @endif
+
+
+ {{-- Filter sidebar (desktop persistent / mobile drawer) --}}
+
+ @include('livewire.storefront.collections.partials.filters')
+
+
+ {{-- Mobile filter drawer --}}
+
+
+
+
+
+ @include('livewire.storefront.collections.partials.filters')
+
+
+
+ Apply
+
+
+
+
+
+ {{-- Product grid --}}
+
+ @if ($products->isEmpty())
+
+
+
+
+
No products found
+
Try adjusting your filters or browse our full collection.
+ @if (count($activeFilters) > 0)
+
+ Clear filters
+
+ @endif
+
+ @else
+
+ @foreach ($products as $product)
+
+ @endforeach
+
+
+
+
+ @endif
+
+
+
diff --git a/resources/views/livewire/storefront/home.blade.php b/resources/views/livewire/storefront/home.blade.php
new file mode 100644
index 00000000..63f2cab3
--- /dev/null
+++ b/resources/views/livewire/storefront/home.blade.php
@@ -0,0 +1,93 @@
+
+ @foreach ($sections as $section)
+ @if ($section === 'hero')
+ {{-- Hero banner --}}
+
+ @if (! empty($hero['image_url']))
+
+ @else
+
+ @endif
+
+
+
+ {{ $hero['heading'] ?? app('current_store')->name }}
+
+ @if (! empty($hero['subheading']))
+
{{ $hero['subheading'] }}
+ @endif
+ @if (! empty($hero['cta_label']) && ! empty($hero['cta_url']))
+
+ {{ $hero['cta_label'] }}
+
+ @endif
+
+
+ @elseif ($section === 'featured_collections')
+ {{-- Featured collections --}}
+
+ Featured collections
+ @if ($featuredCollections->isNotEmpty())
+
+ @endif
+
+ @elseif ($section === 'featured_products')
+ {{-- Featured products --}}
+
+ Featured products
+ @if ($featuredProducts->isNotEmpty())
+
+ @foreach ($featuredProducts as $product)
+
+ @endforeach
+
+ @endif
+
+ @elseif ($section === 'newsletter')
+ {{-- Newsletter signup (client-side only until subscriptions are implemented) --}}
+
+
+
Stay in the loop
+
Subscribe for exclusive offers and updates.
+
+
+
+
+
+ Thanks for subscribing!
+
+
+
+
+ @elseif ($section === 'rich_text' && ! empty($richTextHtml))
+ {{-- Rich text --}}
+
+
+ {!! app(\App\Actions\SanitizeHtml::class)($richTextHtml) !!}
+
+
+ @endif
+ @endforeach
+
diff --git a/resources/views/livewire/storefront/pages/show.blade.php b/resources/views/livewire/storefront/pages/show.blade.php
new file mode 100644
index 00000000..f62d32f2
--- /dev/null
+++ b/resources/views/livewire/storefront/pages/show.blade.php
@@ -0,0 +1,14 @@
+
+
+
+
{{ $page->title }}
+
+ @if (! empty($page->body_html))
+
+ {!! $page->body_html !!}
+
+ @endif
+
diff --git a/resources/views/livewire/storefront/products/show.blade.php b/resources/views/livewire/storefront/products/show.blade.php
new file mode 100644
index 00000000..e623a77d
--- /dev/null
+++ b/resources/views/livewire/storefront/products/show.blade.php
@@ -0,0 +1,219 @@
+
+ @php
+ /** @var \App\Models\Product $product */
+ $variant = $this->selectedVariant();
+ $images = $product->media->filter(fn (\App\Models\ProductMedia $media): bool => $media->status === \App\Enums\MediaStatus::Ready)->values();
+ $primaryCollection = $product->collections->first();
+ $breadcrumbs = [['label' => 'Home', 'url' => route('storefront.home')]];
+ if ($primaryCollection !== null) {
+ $breadcrumbs[] = ['label' => $primaryCollection->title, 'url' => route('storefront.collections.show', ['handle' => $primaryCollection->handle])];
+ }
+ $breadcrumbs[] = ['label' => $product->title];
+ $onSale = $variant !== null && $variant->compare_at_amount !== null && $variant->compare_at_amount > $variant->price_amount;
+
+ $stockStyles = [
+ 'in_stock' => 'text-green-600 dark:text-green-400',
+ 'low_stock' => 'text-amber-600 dark:text-amber-400',
+ 'out_of_stock' => 'text-red-600 dark:text-red-400',
+ 'backorder' => 'text-blue-600 dark:text-blue-400',
+ 'unavailable' => 'text-gray-500 dark:text-gray-400',
+ ];
+
+ $swatchColors = [
+ 'black' => '#111827', 'white' => '#f9fafb', 'gray' => '#6b7280', 'grey' => '#6b7280',
+ 'red' => '#dc2626', 'orange' => '#ea580c', 'amber' => '#d97706', 'yellow' => '#eab308',
+ 'green' => '#16a34a', 'teal' => '#0d9488', 'blue' => '#2563eb', 'navy' => '#1e3a8a',
+ 'purple' => '#9333ea', 'pink' => '#db2777', 'brown' => '#92400e', 'beige' => '#d6c9b0',
+ ];
+ @endphp
+
+
+
+
+ {{-- Image gallery --}}
+
+ {{-- Desktop: main image + thumbnails --}}
+
+
+ @if ($images->isNotEmpty())
+ @foreach ($images as $index => $image)
+
+ @endforeach
+ @else
+
+
+
+
+
+ @endif
+
+ @if ($images->count() > 1)
+
+ @foreach ($images as $index => $image)
+
+
+
+ @endforeach
+
+ @endif
+
+
+ {{-- Mobile: snap-scroll gallery with dots --}}
+
+
+ @forelse ($images as $image)
+
+
+
+ @empty
+
+ @endforelse
+
+ @if ($images->count() > 1)
+
+ @foreach ($images as $index => $image)
+
+ @endforeach
+
+ @endif
+
+
+
+ {{-- Product info --}}
+
+
{{ $product->title }}
+
+ @if (! empty($product->vendor))
+
{{ $product->vendor }}
+ @endif
+
+
+ @if ($variant !== null)
+
+ {{ \App\Support\Money::format($variant->price_amount, $currency) }}
+
+ @if ($onSale)
+ {{ \App\Support\Money::format($variant->compare_at_amount, $currency) }}
+
+ @endif
+ @else
+ Unavailable
+ @endif
+
+
+ {{-- Variant selectors --}}
+ @foreach ($product->options as $option)
+
+
+ {{ $option->name }}@if (! empty($selectedOptions[$option->name])){{ $selectedOptions[$option->name] }} @endif
+
+ @if ($option->name === 'Color')
+
+ @foreach ($option->values as $value)
+ @php $available = $this->isValueAvailable($option, $value->value); @endphp
+
+
+ @endforeach
+
+ @elseif ($option->values->count() <= 6)
+
+ @foreach ($option->values as $value)
+ @php $available = $this->isValueAvailable($option, $value->value); @endphp
+
+ {{ $value->value }}
+
+ @endforeach
+
+ @else
+ {{ $option->name }}
+
+ @foreach ($option->values as $value)
+ name] ?? null) === $value->value)>
+ {{ $value->value }}
+
+ @endforeach
+
+ @endif
+
+ @endforeach
+
+ {{-- Stock messaging --}}
+
+ @if ($stock['state'] === 'in_stock')
+
+ @elseif ($stock['state'] === 'low_stock')
+
+ @elseif ($stock['state'] === 'out_of_stock')
+
+ @else
+
+ @endif
+ {{ $stock['message'] }}
+
+
+ {{-- Quantity + add to cart --}}
+
+ @if ($stock['purchasable'])
+
+ @endif
+
+ {{ $stock['purchasable'] ? 'Add to cart' : ($stock['state'] === 'out_of_stock' ? 'Sold out' : 'Unavailable') }}
+ Adding...
+
+
+
+ {{-- Description --}}
+ @if (! empty($product->description_html))
+
+
+ {!! $product->description_html !!}
+
+ @endif
+
+ {{-- Tags --}}
+ @if (! empty($product->tags))
+
+ @foreach ($product->tags as $tag)
+ {{ $tag }}
+ @endforeach
+
+ @endif
+
+
+
+
+
diff --git a/resources/views/storefront/components/badge.blade.php b/resources/views/storefront/components/badge.blade.php
new file mode 100644
index 00000000..fec893e5
--- /dev/null
+++ b/resources/views/storefront/components/badge.blade.php
@@ -0,0 +1,25 @@
+{{--
+ Styled badge/tag (spec 04 §16).
+
+ Props:
+ - text: string (required) — badge text
+ - variant: string — sale | sold-out | new | default
+--}}
+@props([
+ 'text',
+ 'variant' => 'default',
+])
+
+@php
+ $variantClasses = [
+ 'sale' => 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400',
+ 'sold-out' => 'bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300',
+ 'new' => 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400',
+ 'default' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
+ ];
+ $classes = $variantClasses[$variant] ?? $variantClasses['default'];
+@endphp
+
+class('inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium '.$classes) }}>
+ {{ $variant === 'sale' ? 'On sale: ' : '' }} {{ $text }}
+
diff --git a/resources/views/storefront/components/breadcrumbs.blade.php b/resources/views/storefront/components/breadcrumbs.blade.php
new file mode 100644
index 00000000..5e86bd65
--- /dev/null
+++ b/resources/views/storefront/components/breadcrumbs.blade.php
@@ -0,0 +1,43 @@
+{{--
+ Breadcrumb navigation trail with BreadcrumbList structured data (spec 04 §16).
+
+ Props:
+ - items: array (required) — list of ['label' => string, 'url' => string|null].
+ The last item is the current page and needs no URL.
+--}}
+@props(['items'])
+
+@php
+ $items = array_values($items);
+ $lastIndex = count($items) - 1;
+ $jsonLd = [
+ '@context' => 'https://schema.org',
+ '@type' => 'BreadcrumbList',
+ 'itemListElement' => collect($items)->map(fn (array $item, int $index): array => array_filter([
+ '@type' => 'ListItem',
+ 'position' => $index + 1,
+ 'name' => $item['label'],
+ 'item' => isset($item['url']) ? url($item['url']) : null,
+ ]))->values()->all(),
+ ];
+@endphp
+
+@if (count($items) > 1)
+ class('text-sm') }} aria-label="Breadcrumb">
+
+ @foreach ($items as $index => $item)
+
+ @if ($index > 0)
+ /
+ @endif
+ @if ($index === $lastIndex || empty($item['url']))
+ {{ $item['label'] }}
+ @else
+ {{ $item['label'] }}
+ @endif
+
+ @endforeach
+
+
+
+@endif
diff --git a/resources/views/storefront/components/pagination.blade.php b/resources/views/storefront/components/pagination.blade.php
new file mode 100644
index 00000000..fe66fdb1
--- /dev/null
+++ b/resources/views/storefront/components/pagination.blade.php
@@ -0,0 +1,74 @@
+{{--
+ Numbered pagination with previous/next arrows (spec 04 §4.6 + §16).
+
+ Props:
+ - paginator: LengthAwarePaginator (required)
+--}}
+@props(['paginator'])
+
+@php
+ /** @var \Illuminate\Pagination\LengthAwarePaginator $paginator */
+ $current = $paginator->currentPage();
+ $last = $paginator->lastPage();
+
+ // Windowed page list with ellipsis markers.
+ $pages = [];
+ $window = 2;
+ for ($page = 1; $page <= $last; $page++) {
+ if ($page === 1 || $page === $last || abs($page - $current) <= $window) {
+ $pages[] = $page;
+ } elseif (end($pages) !== '…') {
+ $pages[] = '…';
+ }
+ }
+
+ $linkClasses = 'inline-flex min-w-9 items-center justify-center rounded-md px-3 py-2 text-sm font-medium focus:outline-hidden focus:ring-2 focus:ring-blue-500';
+ $mutedClasses = 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800';
+ $disabledClasses = 'cursor-not-allowed text-gray-400 opacity-60 dark:text-gray-600';
+@endphp
+
+@if ($paginator->hasPages())
+ class('flex items-center justify-between gap-4 sm:justify-center') }} aria-label="Pagination">
+ {{-- Previous --}}
+ @if ($paginator->onFirstPage())
+
+
+ Previous
+
+ @else
+
+
+ Previous
+
+ @endif
+
+ {{-- Mobile page indicator --}}
+ Page {{ $current }} of {{ $last }}
+
+ {{-- Numbered pages --}}
+
+ @foreach ($pages as $page)
+ @if ($page === '…')
+
…
+ @elseif ($page === $current)
+
{{ $page }}
+ @else
+
{{ $page }}
+ @endif
+ @endforeach
+
+
+ {{-- Next --}}
+ @if ($paginator->hasMorePages())
+
+ Next
+
+
+ @else
+
+ Next
+
+
+ @endif
+
+@endif
diff --git a/resources/views/storefront/components/price.blade.php b/resources/views/storefront/components/price.blade.php
new file mode 100644
index 00000000..7aec4de4
--- /dev/null
+++ b/resources/views/storefront/components/price.blade.php
@@ -0,0 +1,25 @@
+{{--
+ Formatted price with optional compare-at price (spec 04 §16).
+
+ Props:
+ - amount: int (required) — price in minor units (cents)
+ - currency: string — ISO 4217 code (default: USD)
+ - compareAtAmount: int|null — original price in minor units for sale display
+--}}
+@props([
+ 'amount',
+ 'currency' => 'USD',
+ 'compareAtAmount' => null,
+])
+
+@php
+ $onSale = $compareAtAmount !== null && (int) $compareAtAmount > (int) $amount;
+@endphp
+
+class('inline-flex flex-wrap items-center gap-x-2 gap-y-1') }}>
+ {{ \App\Support\Money::format((int) $amount, $currency) }}
+ @if ($onSale)
+ {{ \App\Support\Money::format((int) $compareAtAmount, $currency) }}
+
+ @endif
+
diff --git a/resources/views/storefront/components/product-card.blade.php b/resources/views/storefront/components/product-card.blade.php
new file mode 100644
index 00000000..9c5e746a
--- /dev/null
+++ b/resources/views/storefront/components/product-card.blade.php
@@ -0,0 +1,97 @@
+{{--
+ Product card used in grids (spec 04 §4.5 + §16).
+
+ Props:
+ - product: Product (required) — with variants.inventoryItem + media eager-loaded
+ - headingLevel: string — heading tag for the title (default: h3)
+ - showQuickAdd: bool — show quick add / choose options action (default: true)
+--}}
+@props([
+ 'product',
+ 'headingLevel' => 'h3',
+ 'showQuickAdd' => true,
+])
+
+@php
+ /** @var \App\Models\Product $product */
+ $variants = $product->variants;
+ $defaultVariant = $variants->firstWhere('is_default', true) ?? $variants->first();
+ $currency = $defaultVariant?->currency ?? app('current_store')->default_currency ?? 'USD';
+ $minPrice = $variants->min('price_amount');
+ $maxCompareAt = $variants->max('compare_at_amount');
+ $onSale = $maxCompareAt !== null && $minPrice !== null && $maxCompareAt > $minPrice;
+ $soldOut = $variants->isNotEmpty() && $variants->every(
+ fn (\App\Models\ProductVariant $variant): bool => ! $variant->isInStock() && ! $variant->isBackorderable()
+ );
+ $images = $product->media->filter(
+ fn (\App\Models\ProductMedia $media): bool => $media->status === \App\Enums\MediaStatus::Ready
+ )->values();
+ $primaryImage = $images->first();
+ $hoverImage = $images->get(1);
+ $productUrl = route('storefront.products.show', ['handle' => $product->handle]);
+ $singleVariant = $variants->count() === 1;
+@endphp
+
+
diff --git a/resources/views/storefront/components/quantity-selector.blade.php b/resources/views/storefront/components/quantity-selector.blade.php
new file mode 100644
index 00000000..9e44702d
--- /dev/null
+++ b/resources/views/storefront/components/quantity-selector.blade.php
@@ -0,0 +1,52 @@
+{{--
+ Quantity stepper with increment/decrement buttons (spec 04 §16).
+
+ Props:
+ - value: int — current quantity (default: 1)
+ - min: int — minimum allowed value (default: 1)
+ - max: int|null — maximum allowed value (null = unlimited)
+ - wireModel: string (required) — Livewire model binding for the input
+ - compact: bool — smaller variant for the cart drawer
+--}}
+@props([
+ 'value' => 1,
+ 'min' => 1,
+ 'max' => null,
+ 'wireModel',
+ 'compact' => false,
+])
+
+@php
+ $buttonSize = $compact ? 'size-8' : 'size-10';
+ $inputSize = $compact ? 'h-8 w-10' : 'h-10 w-14';
+ $decreased = max($min, (int) $value - 1);
+ $increased = $max === null ? (int) $value + 1 : min((int) $max, (int) $value + 1);
+@endphp
+
+class('inline-flex items-center rounded-md border border-gray-300 dark:border-gray-700') }}>
+
+
+
+
+
+
Quantity
+
+
= (int) $max)
+ class="{{ $buttonSize }} inline-flex items-center justify-center text-gray-600 transition hover:text-gray-900 disabled:cursor-not-allowed disabled:opacity-40 focus:outline-hidden focus:ring-2 focus:ring-inset focus:ring-blue-500 dark:text-gray-300 dark:hover:text-white"
+ aria-label="Increase quantity">
+
+
+
+
+
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php
new file mode 100644
index 00000000..3e2c9785
--- /dev/null
+++ b/resources/views/storefront/layouts/app.blade.php
@@ -0,0 +1,297 @@
+@php
+ /** @var \App\Models\Store $currentStore */
+ $themeSettings = app(\App\Services\ThemeSettingsService::class);
+ $navigation = app(\App\Services\NavigationService::class);
+ $mainMenu = $navigation->forHandle('main-menu');
+ $footerMenu = $navigation->forHandle('footer-menu');
+ $announcement = $themeSettings->get('announcement', []);
+ $logoUrl = $themeSettings->get('header.logo_url');
+ $isSticky = (bool) $themeSettings->get('header.sticky', false);
+ $darkMode = $themeSettings->get('dark_mode', 'system');
+ $socialLinks = $themeSettings->get('footer.social', []);
+ $pageTitle = $title ?? $currentStore->name;
+ $organizationJsonLd = [
+ '@context' => 'https://schema.org',
+ '@type' => 'Organization',
+ 'name' => $currentStore->name,
+ 'url' => url('/'),
+ ];
+@endphp
+
+
+
+
+
+ {{ $pageTitle }}
+ @if (! empty($metaDescription))
+
+ @endif
+
+ @if (! empty($og) && is_array($og))
+
+ @if (! empty($og['description']))
+
+ @endif
+ @if (! empty($og['image']))
+
+ @endif
+
+
+ @if (! empty($og['price_amount']))
+
+
+ @endif
+ @endif
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+
+
+
+
+ Skip to main content
+
+
+ {{-- Announcement bar --}}
+ @if (! empty($announcement['enabled']) && ! empty($announcement['text']))
+
+
+
+ {{ $announcement['text'] }}
+ @if (! empty($announcement['link']))
+ Learn more
+ @endif
+
+
+
+
+
+
+
+
+ @endif
+
+ {{-- Header --}}
+
+
+ {{-- Main content --}}
+
+ {{ $slot }}
+
+
+ {{-- Footer --}}
+
+
+ {{-- Cart drawer shell (inner content replaced by the real cart component in Phase 4) --}}
+
+
+
+
+
+
+
+
+
+
Cart is empty
+
+ Continue shopping
+
+
+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php
index 8405d81d..fa0b31bd 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,8 +1,17 @@
group(function (): void {
- Route::get('/', fn (): string => 'storefront ok');
+ Route::livewire('/', Home::class)->name('storefront.home');
+ Route::livewire('/collections', CollectionsIndex::class)->name('storefront.collections.index');
+ Route::livewire('/collections/{handle}', CollectionsShow::class)->name('storefront.collections.show');
+ Route::livewire('/products/{handle}', ProductsShow::class)->name('storefront.products.show');
+ Route::livewire('/pages/{handle}', PagesShow::class)->name('storefront.pages.show');
});
diff --git a/specs/progress.md b/specs/progress.md
index 7a7e9404..a922537f 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -21,6 +21,8 @@
| 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 |
+| 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 |
+
## Phase Checklist
### Phase 1 — Foundation
@@ -43,10 +45,10 @@
- [x] Tests: ProductCrudTest, VariantTest, InventoryTest, CollectionTest, MediaUploadTest, HandleGeneratorTest
### Phase 3 — Themes / Pages / Navigation / Storefront layout
-- [ ] Models: Theme, ThemeFile, ThemeSettings, Page, NavigationMenu, NavigationItem
-- [ ] NavigationService, ThemeSettings service
-- [ ] Storefront layout + components (product-card, price, badge, etc.)
-- [ ] Storefront Livewire: Home, Collections Index/Show, Products Show, Pages Show
+- [x] Models: Theme, ThemeFile, ThemeSettings, Page, NavigationMenu, NavigationItem
+- [x] NavigationService, ThemeSettings service
+- [x] Storefront layout + components (product-card, price, badge, etc.)
+- [x] Storefront Livewire: Home, Collections Index/Show, Products Show, Pages Show
### Phase 4 — Cart / Checkout / Discounts / Shipping / Taxes
- [ ] Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
diff --git a/tests/Feature/Storefront/BrowsingTest.php b/tests/Feature/Storefront/BrowsingTest.php
new file mode 100644
index 00000000..bbf767f3
--- /dev/null
+++ b/tests/Feature/Storefront/BrowsingTest.php
@@ -0,0 +1,283 @@
+handle.'.test'.$path;
+}
+
+test('home page renders for a store with a published theme and visible products', function () {
+ $store = $this->createStore();
+ Theme::factory()->published()->create(['store_id' => $store->id]);
+ Product::factory()->active()->withVariants(1, ['price_amount' => 2499])->create([
+ 'store_id' => $store->id,
+ 'title' => 'Everyday Sneaker',
+ ]);
+
+ $response = $this->get(storefrontUrl($store));
+
+ $response->assertOk()
+ ->assertSee('Everyday Sneaker')
+ ->assertSee('24.99 USD');
+});
+
+test('unknown product handle returns 404', function () {
+ $store = $this->createStore();
+
+ $this->get(storefrontUrl($store, '/products/no-such-product'))->assertNotFound();
+});
+
+test('draft product returns 404 on the storefront', function () {
+ $store = $this->createStore();
+ $product = Product::factory()->create(['store_id' => $store->id]);
+
+ $this->get(storefrontUrl($store, '/products/'.$product->handle))->assertNotFound();
+});
+
+test('collections index lists active collections only', function () {
+ $store = $this->createStore();
+ Collection::factory()->create(['store_id' => $store->id, 'title' => 'Summer Picks']);
+ Collection::factory()->draft()->create(['store_id' => $store->id, 'title' => 'Hidden Vault']);
+
+ $this->get(storefrontUrl($store, '/collections'))
+ ->assertOk()
+ ->assertSee('Summer Picks')
+ ->assertDontSee('Hidden Vault');
+});
+
+test('collection show lists only the visible products of that collection', function () {
+ $store = $this->createStore();
+ $collection = Collection::factory()->create(['store_id' => $store->id, 'title' => 'Essentials']);
+
+ $visible = Product::factory()->active()->withVariants(1)->create(['store_id' => $store->id, 'title' => 'Visible Tee']);
+ $draft = Product::factory()->create(['store_id' => $store->id, 'title' => 'Draft Tee']);
+ $outsider = Product::factory()->active()->create(['store_id' => $store->id, 'title' => 'Outside Tee']);
+
+ $collection->products()->attach($visible->id, ['position' => 0]);
+ $collection->products()->attach($draft->id, ['position' => 1]);
+
+ $this->get(storefrontUrl($store, '/collections/'.$collection->handle))
+ ->assertOk()
+ ->assertSee('Essentials')
+ ->assertSee('Visible Tee')
+ ->assertDontSee('Draft Tee')
+ ->assertDontSee('Outside Tee');
+});
+
+test('draft collection returns 404 on the storefront', function () {
+ $store = $this->createStore();
+ $collection = Collection::factory()->draft()->create(['store_id' => $store->id]);
+
+ $this->get(storefrontUrl($store, '/collections/'.$collection->handle))->assertNotFound();
+});
+
+test('collection show paginates twelve products per page', function () {
+ $store = $this->createStore();
+ $collection = Collection::factory()->create(['store_id' => $store->id]);
+
+ for ($i = 1; $i <= 15; $i++) {
+ $product = Product::factory()->active()->withVariants(1)->create([
+ 'store_id' => $store->id,
+ 'title' => sprintf('Product %02d', $i),
+ ]);
+ $collection->products()->attach($product->id, ['position' => $i - 1]);
+ }
+
+ $pageOne = $this->get(storefrontUrl($store, '/collections/'.$collection->handle));
+ $pageOne->assertOk()
+ ->assertSee('Product 01')
+ ->assertSee('Product 12')
+ ->assertDontSee('Product 13')
+ ->assertSee('page=2');
+
+ $pageTwo = $this->get(storefrontUrl($store, '/collections/'.$collection->handle.'?page=2'));
+ $pageTwo->assertOk()
+ ->assertSee('Product 13')
+ ->assertSee('Product 15')
+ ->assertDontSee('Product 01');
+});
+
+test('product page shows title, formatted price, and variant options', function () {
+ $store = $this->createStore();
+ $product = Product::factory()->active()->create([
+ 'store_id' => $store->id,
+ 'title' => 'Cotton Shirt',
+ ]);
+
+ $option = ProductOption::factory()->create(['product_id' => $product->id, 'name' => 'Size', 'position' => 0]);
+ $small = ProductOptionValue::factory()->create(['product_option_id' => $option->id, 'value' => 'Small', 'position' => 0]);
+ $medium = ProductOptionValue::factory()->create(['product_option_id' => $option->id, 'value' => 'Medium', 'position' => 1]);
+
+ $variant = ProductVariant::factory()->default()->withInventory(25)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2499,
+ ]);
+ $variant->optionValues()->attach($small->id);
+
+ $second = ProductVariant::factory()->withInventory(25)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2499,
+ 'position' => 1,
+ ]);
+ $second->optionValues()->attach($medium->id);
+
+ $this->get(storefrontUrl($store, '/products/'.$product->handle))
+ ->assertOk()
+ ->assertSee('Cotton Shirt')
+ ->assertSee('24.99 USD')
+ ->assertSee('Size')
+ ->assertSee('Small')
+ ->assertSee('Medium')
+ ->assertSee('In stock');
+});
+
+test('out-of-stock variant with deny policy shows sold out and a disabled button', function () {
+ $store = $this->createStore();
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ ProductVariant::factory()->default()->withInventory(0)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 1000,
+ ]);
+
+ $response = $this->get(storefrontUrl($store, '/products/'.$product->handle));
+
+ $response->assertOk()
+ ->assertSee('Out of stock')
+ ->assertSee('Sold out');
+
+ expect($response->getContent())->toContain('disabled');
+});
+
+test('zero-stock variant with continue policy shows backorder and an enabled button', function () {
+ $store = $this->createStore();
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->default()->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 1000,
+ ]);
+ $variant->inventoryItem()->create([
+ 'store_id' => $store->id,
+ 'quantity_on_hand' => 0,
+ 'policy' => InventoryPolicy::Continue,
+ ]);
+
+ $this->get(storefrontUrl($store, '/products/'.$product->handle))
+ ->assertOk()
+ ->assertSee('Available on backorder')
+ ->assertSee('Add to cart')
+ ->assertDontSee('Sold out');
+});
+
+test('add to cart dispatches the event with variant id and quantity', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->default()->withInventory(5)->create(['product_id' => $product->id]);
+
+ Livewire::test(ProductPage::class, ['handle' => $product->handle])
+ ->call('addToCart')
+ ->assertDispatched('add-to-cart', variantId: $variant->id, quantity: 1);
+});
+
+test('published page renders its title and sanitized body', function () {
+ $store = $this->createStore();
+ Page::factory()->published()->create([
+ 'store_id' => $store->id,
+ 'title' => 'About Us',
+ 'handle' => 'about-us',
+ 'body_html' => 'We make things.
',
+ ]);
+
+ $this->get(storefrontUrl($store, '/pages/about-us'))
+ ->assertOk()
+ ->assertSee('About Us')
+ ->assertSee('We make things.')
+ ->assertDontSee('alert(1)');
+});
+
+test('draft page returns 404 on the storefront', function () {
+ $store = $this->createStore();
+ $page = Page::factory()->create(['store_id' => $store->id]);
+
+ $this->get(storefrontUrl($store, '/pages/'.$page->handle))->assertNotFound();
+});
+
+test('page handle is generated from the title when empty', function () {
+ $store = $this->createStore();
+
+ $page = Page::factory()->create([
+ 'store_id' => $store->id,
+ 'title' => 'Terms of Service',
+ 'handle' => null,
+ ]);
+
+ expect($page->handle)->toBe('terms-of-service');
+});
+
+test('navigation tree resolves item urls and the header renders the labels', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $menu = NavigationMenu::factory()->mainMenu()->create(['store_id' => $store->id]);
+ $page = Page::factory()->published()->create(['store_id' => $store->id, 'handle' => 'about-us']);
+ $collection = Collection::factory()->create(['store_id' => $store->id, 'handle' => 'summer']);
+ $product = Product::factory()->active()->create(['store_id' => $store->id, 'handle' => 'sneaker']);
+
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => NavigationItemType::Page, 'label' => 'Our Story', 'url' => null, 'resource_id' => $page->id, 'position' => 0]);
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => NavigationItemType::Collection, 'label' => 'Summer Drop', 'url' => null, 'resource_id' => $collection->id, 'position' => 1]);
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => NavigationItemType::Product, 'label' => 'Hero Sneaker', 'url' => null, 'resource_id' => $product->id, 'position' => 2]);
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'type' => NavigationItemType::Link, 'label' => 'Gift Cards', 'url' => '/gift-cards', 'resource_id' => null, 'position' => 3]);
+
+ $service = app(NavigationService::class);
+ $tree = $service->buildTree($menu->refresh());
+
+ expect($tree)->toHaveCount(4)
+ ->and($tree[0]['url'])->toBe('/pages/about-us')
+ ->and($tree[1]['url'])->toBe('/collections/summer')
+ ->and($tree[2]['url'])->toBe('/products/sneaker')
+ ->and($tree[3]['url'])->toBe('/gift-cards');
+
+ expect($service->resolveUrl($menu->items->first()))->toBe('/pages/about-us');
+
+ $this->get(storefrontUrl($store))
+ ->assertOk()
+ ->assertSee('Our Story')
+ ->assertSee('Summer Drop')
+ ->assertSee('Hero Sneaker')
+ ->assertSee('Gift Cards');
+});
+
+test('navigation cache is invalidated when menu items change', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $menu = NavigationMenu::factory()->mainMenu()->create(['store_id' => $store->id]);
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'label' => 'First Link', 'url' => '/first', 'position' => 0]);
+
+ $service = app(NavigationService::class);
+
+ expect(collect($service->forHandle('main-menu'))->pluck('label')->all())->toBe(['First Link']);
+
+ NavigationItem::factory()->create(['menu_id' => $menu->id, 'label' => 'Second Link', 'url' => '/second', 'position' => 1]);
+
+ expect(collect($service->forHandle('main-menu'))->pluck('label')->all())->toBe(['First Link', 'Second Link']);
+});
diff --git a/tests/Feature/Storefront/ThemeSettingsTest.php b/tests/Feature/Storefront/ThemeSettingsTest.php
new file mode 100644
index 00000000..230ddb12
--- /dev/null
+++ b/tests/Feature/Storefront/ThemeSettingsTest.php
@@ -0,0 +1,93 @@
+createStore();
+ $this->bindStore($store);
+
+ $service = app(ThemeSettingsService::class);
+
+ expect($service->all())->toBe(ThemeSettingsService::DEFAULTS)
+ ->and($service->get('announcement.enabled'))->toBeFalse()
+ ->and($service->get('hero.cta_label'))->toBe('Shop now')
+ ->and($service->get('sections_order'))->toBe(['hero', 'featured_collections', 'featured_products', 'newsletter', 'rich_text']);
+});
+
+test('published theme settings win over the defaults', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $theme = Theme::factory()->published()->create(['store_id' => $store->id]);
+ $theme->settings()->create([
+ 'settings_json' => [
+ 'announcement' => ['enabled' => true, 'text' => 'Free shipping this week'],
+ 'hero' => ['cta_label' => 'Browse the sale'],
+ ],
+ ]);
+
+ $service = app(ThemeSettingsService::class);
+
+ expect($service->get('announcement.enabled'))->toBeTrue()
+ ->and($service->get('announcement.text'))->toBe('Free shipping this week')
+ ->and($service->get('hero.cta_label'))->toBe('Browse the sale')
+ // Untouched keys keep their defaults.
+ ->and($service->get('hero.enabled'))->toBeTrue()
+ ->and($service->get('hero.cta_url'))->toBe('/collections');
+});
+
+test('draft theme settings are ignored', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $theme = Theme::factory()->create(['store_id' => $store->id]);
+ $theme->settings()->create(['settings_json' => ['hero' => ['cta_label' => 'Hidden label']]]);
+
+ expect(app(ThemeSettingsService::class)->get('hero.cta_label'))->toBe('Shop now');
+});
+
+test('saving settings invalidates the cached values', function () {
+ $store = $this->createStore();
+ $this->bindStore($store);
+
+ $theme = Theme::factory()->published()->create(['store_id' => $store->id]);
+ $settings = $theme->settings()->create(['settings_json' => ['hero' => ['cta_label' => 'Before']]]);
+
+ $service = app(ThemeSettingsService::class);
+
+ expect($service->get('hero.cta_label'))->toBe('Before');
+
+ $settings->update(['settings_json' => ['hero' => ['cta_label' => 'After']]]);
+
+ expect($service->get('hero.cta_label'))->toBe('After');
+});
+
+test('publishing a theme demotes the previously published theme', function () {
+ $store = $this->createStore();
+
+ $first = Theme::factory()->published()->create(['store_id' => $store->id]);
+ $second = Theme::factory()->create(['store_id' => $store->id]);
+
+ $second->publish();
+
+ expect($second->refresh()->isPublished())->toBeTrue()
+ ->and($first->refresh()->isPublished())->toBeFalse()
+ ->and($second->published_at)->not->toBeNull();
+});
+
+test('duplicating a theme copies files and settings as a draft', function () {
+ $store = $this->createStore();
+
+ $theme = Theme::factory()->published()->create(['store_id' => $store->id]);
+ ThemeFile::factory()->count(2)->create(['theme_id' => $theme->id]);
+ $theme->settings()->create(['settings_json' => ['hero' => ['heading' => 'Copied hero']]]);
+
+ $copy = $theme->duplicate('Winter copy');
+
+ expect($copy->name)->toBe('Winter copy')
+ ->and($copy->isPublished())->toBeFalse()
+ ->and($copy->files)->toHaveCount(2)
+ ->and($copy->settings->settings_json)->toBe(['hero' => ['heading' => 'Copied hero']]);
+});
diff --git a/tests/Unit/MoneyTest.php b/tests/Unit/MoneyTest.php
new file mode 100644
index 00000000..ebf4f097
--- /dev/null
+++ b/tests/Unit/MoneyTest.php
@@ -0,0 +1,24 @@
+toBe('24.99 EUR');
+});
+
+test('formats free amounts as 0.00', function () {
+ expect(Money::format(0, 'EUR'))->toBe('0.00 EUR');
+});
+
+test('adds a comma as thousands separator', function () {
+ expect(Money::format(149900, 'EUR'))->toBe('1,499.00 EUR')
+ ->and(Money::format(123456789, 'USD'))->toBe('1,234,567.89 USD');
+});
+
+test('formats negative amounts with a leading minus sign', function () {
+ expect(Money::format(-1250, 'EUR'))->toBe('-12.50 EUR');
+});
+
+test('uppercases the currency code', function () {
+ expect(Money::format(100, 'usd'))->toBe('1.00 USD');
+});
From 42e27c4741d608df908855172a6b9e6e4f0c913b Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 14:28:37 +0200
Subject: [PATCH 06/29] Phase 4: Cart, checkout, discounts, shipping, taxes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Models + factories: Cart, CartLine, Checkout, ShippingZone/Rate, TaxSettings, Discount
- PricingEngine deterministic 7-step pipeline, largest-remainder allocation
- DiscountService (6 error codes, automatic stacking), ShippingCalculator (zone specificity), TaxCalculator + Manual/Stripe providers
- CartService with optimistic versioning, session binding, merge-on-login
- CheckoutService state machine (started→addressed→shipping_selected→payment_selected→expired)
- Storefront REST API: cart + checkout endpoints per spec 02 (409/410/422 semantics)
- Livewire cart drawer, cart page, checkout stepper (payment step placeholder for Phase 5)
- Scheduled jobs: ExpireAbandonedCheckouts (15min), CleanupAbandonedCarts (daily)
- 114 new tests: 219 passed total
---
app/Contracts/TaxProvider.php | 16 +
app/Events/CheckoutAddressed.php | 14 +
app/Events/CheckoutExpired.php | 14 +
app/Events/CheckoutShippingSelected.php | 14 +
.../CartVersionMismatchException.php | 19 +
.../InvalidCheckoutTransitionException.php | 17 +
.../Api/Storefront/CartController.php | 129 ++++++
.../Api/Storefront/CheckoutController.php | 149 +++++++
app/Http/Requests/ApplyDiscountRequest.php | 23 +
.../Requests/SetCheckoutAddressRequest.php | 50 +++
.../Resources/Storefront/CartResource.php | 67 +++
.../Resources/Storefront/CheckoutResource.php | 132 ++++++
app/Jobs/CleanupAbandonedCarts.php | 48 ++
app/Jobs/ExpireAbandonedCheckouts.php | 38 ++
app/Livewire/Storefront/Cart/Show.php | 40 ++
app/Livewire/Storefront/CartDrawer.php | 72 +++
app/Livewire/Storefront/Checkout/Show.php | 308 +++++++++++++
.../Storefront/Concerns/InteractsWithCart.php | 194 +++++++++
app/Livewire/Storefront/Products/Show.php | 17 +-
app/Models/Cart.php | 122 ++++++
app/Models/CartLine.php | 81 ++++
app/Models/Checkout.php | 99 +++++
app/Models/Concerns/BelongsToStore.php | 12 +
app/Models/Discount.php | 66 +++
app/Models/ShippingRate.php | 58 +++
app/Models/ShippingZone.php | 56 +++
app/Models/TaxSettings.php | 72 +++
app/Services/CartService.php | 240 ++++++++++
app/Services/CheckoutService.php | 409 ++++++++++++++++++
app/Services/DiscountService.php | 205 +++++++++
app/Services/PricingEngine.php | 135 ++++++
app/Services/ShippingCalculator.php | 155 +++++++
app/Services/Tax/ManualTaxProvider.php | 113 +++++
app/Services/Tax/StripeTaxProvider.php | 29 ++
app/Services/TaxCalculator.php | 60 +++
app/ValueObjects/Address.php | 72 +++
app/ValueObjects/DiscountValidationResult.php | 28 ++
app/ValueObjects/PricingResult.php | 48 ++
app/ValueObjects/ShippingRateVO.php | 23 +
app/ValueObjects/TaxCalculationRequest.php | 21 +
app/ValueObjects/TaxCalculationResult.php | 30 ++
app/ValueObjects/TaxLine.php | 28 ++
bootstrap/app.php | 25 +-
database/factories/CartFactory.php | 49 +++
database/factories/CartLineFactory.php | 43 ++
database/factories/CheckoutFactory.php | 58 +++
database/factories/DiscountFactory.php | 113 +++++
database/factories/ShippingRateFactory.php | 86 ++++
database/factories/ShippingZoneFactory.php | 27 ++
database/factories/TaxSettingsFactory.php | 49 +++
.../livewire/storefront/cart-drawer.blade.php | 137 ++++++
.../livewire/storefront/cart/show.blade.php | 174 ++++++++
.../storefront/checkout/show.blade.php | 269 ++++++++++++
.../views/storefront/layouts/app.blade.php | 33 +-
routes/api.php | 20 +-
routes/console.php | 7 +
routes/web.php | 4 +
specs/progress.md | 14 +-
tests/Feature/Cart/CartApiTest.php | 172 ++++++++
tests/Feature/Cart/CartServiceTest.php | 207 +++++++++
tests/Feature/Checkout/CheckoutFlowTest.php | 168 +++++++
tests/Feature/Checkout/CheckoutStateTest.php | 201 +++++++++
tests/Feature/Checkout/DiscountTest.php | 142 ++++++
.../Checkout/PricingIntegrationTest.php | 149 +++++++
tests/Feature/Checkout/ShippingTest.php | 123 ++++++
tests/Feature/Checkout/TaxTest.php | 107 +++++
tests/Feature/Storefront/BrowsingTest.php | 11 +-
tests/Feature/Storefront/CartUiTest.php | 184 ++++++++
tests/Unit/CartVersionTest.php | 73 ++++
tests/Unit/DiscountCalculatorTest.php | 190 ++++++++
tests/Unit/PricingEngineTest.php | 207 +++++++++
tests/Unit/ShippingCalculatorTest.php | 179 ++++++++
tests/Unit/TaxCalculatorTest.php | 46 ++
73 files changed, 6750 insertions(+), 40 deletions(-)
create mode 100644 app/Contracts/TaxProvider.php
create mode 100644 app/Events/CheckoutAddressed.php
create mode 100644 app/Events/CheckoutExpired.php
create mode 100644 app/Events/CheckoutShippingSelected.php
create mode 100644 app/Exceptions/CartVersionMismatchException.php
create mode 100644 app/Exceptions/InvalidCheckoutTransitionException.php
create mode 100644 app/Http/Controllers/Api/Storefront/CartController.php
create mode 100644 app/Http/Controllers/Api/Storefront/CheckoutController.php
create mode 100644 app/Http/Requests/ApplyDiscountRequest.php
create mode 100644 app/Http/Requests/SetCheckoutAddressRequest.php
create mode 100644 app/Http/Resources/Storefront/CartResource.php
create mode 100644 app/Http/Resources/Storefront/CheckoutResource.php
create mode 100644 app/Jobs/CleanupAbandonedCarts.php
create mode 100644 app/Jobs/ExpireAbandonedCheckouts.php
create mode 100644 app/Livewire/Storefront/Cart/Show.php
create mode 100644 app/Livewire/Storefront/CartDrawer.php
create mode 100644 app/Livewire/Storefront/Checkout/Show.php
create mode 100644 app/Livewire/Storefront/Concerns/InteractsWithCart.php
create mode 100644 app/Models/Cart.php
create mode 100644 app/Models/CartLine.php
create mode 100644 app/Models/Checkout.php
create mode 100644 app/Models/Discount.php
create mode 100644 app/Models/ShippingRate.php
create mode 100644 app/Models/ShippingZone.php
create mode 100644 app/Models/TaxSettings.php
create mode 100644 app/Services/CartService.php
create mode 100644 app/Services/CheckoutService.php
create mode 100644 app/Services/DiscountService.php
create mode 100644 app/Services/PricingEngine.php
create mode 100644 app/Services/ShippingCalculator.php
create mode 100644 app/Services/Tax/ManualTaxProvider.php
create mode 100644 app/Services/Tax/StripeTaxProvider.php
create mode 100644 app/Services/TaxCalculator.php
create mode 100644 app/ValueObjects/Address.php
create mode 100644 app/ValueObjects/DiscountValidationResult.php
create mode 100644 app/ValueObjects/PricingResult.php
create mode 100644 app/ValueObjects/ShippingRateVO.php
create mode 100644 app/ValueObjects/TaxCalculationRequest.php
create mode 100644 app/ValueObjects/TaxCalculationResult.php
create mode 100644 app/ValueObjects/TaxLine.php
create mode 100644 database/factories/CartFactory.php
create mode 100644 database/factories/CartLineFactory.php
create mode 100644 database/factories/CheckoutFactory.php
create mode 100644 database/factories/DiscountFactory.php
create mode 100644 database/factories/ShippingRateFactory.php
create mode 100644 database/factories/ShippingZoneFactory.php
create mode 100644 database/factories/TaxSettingsFactory.php
create mode 100644 resources/views/livewire/storefront/cart-drawer.blade.php
create mode 100644 resources/views/livewire/storefront/cart/show.blade.php
create mode 100644 resources/views/livewire/storefront/checkout/show.blade.php
create mode 100644 tests/Feature/Cart/CartApiTest.php
create mode 100644 tests/Feature/Cart/CartServiceTest.php
create mode 100644 tests/Feature/Checkout/CheckoutFlowTest.php
create mode 100644 tests/Feature/Checkout/CheckoutStateTest.php
create mode 100644 tests/Feature/Checkout/DiscountTest.php
create mode 100644 tests/Feature/Checkout/PricingIntegrationTest.php
create mode 100644 tests/Feature/Checkout/ShippingTest.php
create mode 100644 tests/Feature/Checkout/TaxTest.php
create mode 100644 tests/Feature/Storefront/CartUiTest.php
create mode 100644 tests/Unit/CartVersionTest.php
create mode 100644 tests/Unit/DiscountCalculatorTest.php
create mode 100644 tests/Unit/PricingEngineTest.php
create mode 100644 tests/Unit/ShippingCalculatorTest.php
create mode 100644 tests/Unit/TaxCalculatorTest.php
diff --git a/app/Contracts/TaxProvider.php b/app/Contracts/TaxProvider.php
new file mode 100644
index 00000000..4be216d1
--- /dev/null
+++ b/app/Contracts/TaxProvider.php
@@ -0,0 +1,16 @@
+cart_version}.");
+ }
+}
diff --git a/app/Exceptions/InvalidCheckoutTransitionException.php b/app/Exceptions/InvalidCheckoutTransitionException.php
new file mode 100644
index 00000000..e6bd2128
--- /dev/null
+++ b/app/Exceptions/InvalidCheckoutTransitionException.php
@@ -0,0 +1,17 @@
+validate([
+ 'currency' => ['nullable', 'string', 'size:3', 'alpha'],
+ ]);
+
+ $cart = $this->carts->create(app('current_store'), $request->user('customer'));
+
+ if (! empty($validated['currency'])) {
+ $cart->update(['currency' => strtoupper($validated['currency'])]);
+ }
+
+ return (new CartResource($cart))
+ ->response()
+ ->setStatusCode(201);
+ }
+
+ /**
+ * GET /api/storefront/v1/carts/{cartId} — retrieve a cart.
+ */
+ public function show(int $cartId): CartResource
+ {
+ return new CartResource(Cart::findOrFail($cartId));
+ }
+
+ /**
+ * POST /api/storefront/v1/carts/{cartId}/lines — add a line item.
+ */
+ public function addLine(Request $request, int $cartId): JsonResponse
+ {
+ $validated = $request->validate([
+ 'variant_id' => ['required', 'integer'],
+ 'quantity' => ['required', 'integer', 'min:1', 'max:9999'],
+ 'cart_version' => ['sometimes', 'integer', 'min:1'],
+ ]);
+
+ $cart = Cart::findOrFail($cartId);
+ $this->assertVersion($cart, $validated['cart_version'] ?? null);
+
+ try {
+ $this->carts->addLine($cart, (int) $validated['variant_id'], (int) $validated['quantity']);
+ } catch (InsufficientInventoryException) {
+ throw ValidationException::withMessages([
+ 'variant_id' => ['The selected variant is out of stock.'],
+ ]);
+ }
+
+ return (new CartResource($cart->refresh()))
+ ->response()
+ ->setStatusCode(201);
+ }
+
+ /**
+ * PUT /api/storefront/v1/carts/{cartId}/lines/{lineId} — update quantity.
+ */
+ public function updateLine(Request $request, int $cartId, int $lineId): CartResource
+ {
+ $validated = $request->validate([
+ 'quantity' => ['required', 'integer', 'min:1', 'max:9999'],
+ 'cart_version' => ['required', 'integer', 'min:1'],
+ ]);
+
+ $cart = Cart::findOrFail($cartId);
+ $this->assertVersion($cart, $validated['cart_version']);
+
+ try {
+ $this->carts->updateLineQuantity($cart, $lineId, (int) $validated['quantity']);
+ } catch (InsufficientInventoryException) {
+ throw ValidationException::withMessages([
+ 'quantity' => ['The selected variant is out of stock.'],
+ ]);
+ }
+
+ return new CartResource($cart->refresh());
+ }
+
+ /**
+ * DELETE /api/storefront/v1/carts/{cartId}/lines/{lineId} — remove a line.
+ */
+ public function removeLine(Request $request, int $cartId, int $lineId): CartResource
+ {
+ $validated = $request->validate([
+ 'cart_version' => ['required', 'integer', 'min:1'],
+ ]);
+
+ $cart = Cart::findOrFail($cartId);
+ $this->assertVersion($cart, $validated['cart_version']);
+
+ $this->carts->removeLine($cart, $lineId);
+
+ return new CartResource($cart->refresh());
+ }
+
+ /**
+ * Verify the expected version when the client sends one.
+ *
+ * @throws CartVersionMismatchException
+ */
+ private function assertVersion(Cart $cart, ?int $expectedVersion): void
+ {
+ if ($expectedVersion !== null) {
+ $this->carts->assertVersion($cart, $expectedVersion);
+ }
+ }
+}
diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php
new file mode 100644
index 00000000..01b0ef8c
--- /dev/null
+++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php
@@ -0,0 +1,149 @@
+validate([
+ 'cart_id' => ['required', 'integer'],
+ 'email' => ['required', 'email', 'max:255'],
+ ]);
+
+ $cart = Cart::findOrFail((int) $validated['cart_id']);
+
+ $checkout = $this->checkoutService->createFromCart(
+ $cart,
+ $validated['email'],
+ $request->user('customer'),
+ );
+
+ return (new CheckoutResource($checkout))
+ ->response()
+ ->setStatusCode(201);
+ }
+
+ /**
+ * GET /api/storefront/v1/checkouts/{checkoutId} — current state (410 when expired).
+ */
+ public function show(int $checkoutId): CheckoutResource
+ {
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ return new CheckoutResource($checkout);
+ }
+
+ /**
+ * PUT /api/storefront/v1/checkouts/{checkoutId}/address.
+ */
+ public function setAddress(SetCheckoutAddressRequest $request, int $checkoutId): CheckoutResource
+ {
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ $this->checkoutService->setAddress($checkout, $request->validated());
+
+ return new CheckoutResource($checkout->refresh());
+ }
+
+ /**
+ * PUT /api/storefront/v1/checkouts/{checkoutId}/shipping-method.
+ */
+ public function setShippingMethod(Request $request, int $checkoutId): CheckoutResource
+ {
+ $validated = $request->validate([
+ 'shipping_method_id' => ['required', 'integer'],
+ ]);
+
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ $this->checkoutService->setShippingMethod($checkout, (int) $validated['shipping_method_id']);
+
+ return new CheckoutResource($checkout->refresh());
+ }
+
+ /**
+ * PUT /api/storefront/v1/checkouts/{checkoutId}/payment-method.
+ */
+ public function selectPaymentMethod(Request $request, int $checkoutId): CheckoutResource
+ {
+ $validated = $request->validate([
+ 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
+ ]);
+
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ $this->checkoutService->selectPaymentMethod($checkout, $validated['payment_method']);
+
+ return new CheckoutResource($checkout->refresh());
+ }
+
+ /**
+ * POST /api/storefront/v1/checkouts/{checkoutId}/apply-discount.
+ */
+ public function applyDiscount(ApplyDiscountRequest $request, int $checkoutId): JsonResponse|CheckoutResource
+ {
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ $result = $this->checkoutService->applyDiscount($checkout, $request->validated('code'));
+
+ if (! $result->valid) {
+ $status = in_array($result->errorCode, ['discount_expired', 'discount_usage_limit_reached'], true)
+ ? 400
+ : 422;
+
+ return response()->json([
+ 'message' => $result->errorMessage,
+ 'error_code' => $result->errorCode,
+ ], $status);
+ }
+
+ return new CheckoutResource($checkout->refresh());
+ }
+
+ /**
+ * DELETE /api/storefront/v1/checkouts/{checkoutId}/discount.
+ */
+ public function removeDiscount(int $checkoutId): CheckoutResource
+ {
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ abort_if($checkout->discount_code === null, 404, 'No discount applied to this checkout.');
+
+ $this->checkoutService->removeDiscount($checkout);
+
+ return new CheckoutResource($checkout->refresh());
+ }
+
+ /**
+ * Abort with 410 when the checkout has expired.
+ */
+ private function guardNotExpired(Checkout $checkout): void
+ {
+ abort_if($checkout->isExpired(), 410, 'The checkout has expired.');
+ }
+}
diff --git a/app/Http/Requests/ApplyDiscountRequest.php b/app/Http/Requests/ApplyDiscountRequest.php
new file mode 100644
index 00000000..7536998d
--- /dev/null
+++ b/app/Http/Requests/ApplyDiscountRequest.php
@@ -0,0 +1,23 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'code' => ['required', 'string', 'max:50'],
+ ];
+ }
+}
diff --git a/app/Http/Requests/SetCheckoutAddressRequest.php b/app/Http/Requests/SetCheckoutAddressRequest.php
new file mode 100644
index 00000000..80b42332
--- /dev/null
+++ b/app/Http/Requests/SetCheckoutAddressRequest.php
@@ -0,0 +1,50 @@
+>
+ */
+ public function rules(): array
+ {
+ return [
+ 'email' => ['sometimes', 'email', 'max:255'],
+ 'shipping_address' => ['required', 'array'],
+ 'shipping_address.first_name' => ['required', 'string', 'max:255'],
+ 'shipping_address.last_name' => ['required', 'string', 'max:255'],
+ 'shipping_address.address1' => ['required', 'string', 'max:500'],
+ 'shipping_address.address2' => ['nullable', 'string', 'max:500'],
+ 'shipping_address.company' => ['nullable', 'string', 'max:255'],
+ 'shipping_address.city' => ['required', 'string', 'max:255'],
+ 'shipping_address.province' => ['nullable', 'string', 'max:255'],
+ 'shipping_address.province_code' => ['nullable', 'string', 'max:10'],
+ 'shipping_address.country' => ['required', 'string', 'max:255'],
+ 'shipping_address.country_code' => ['required', 'string', 'size:2', 'alpha'],
+ 'shipping_address.postal_code' => ['required', 'string', 'max:20'],
+ 'shipping_address.phone' => ['nullable', 'string', 'max:50'],
+ 'billing_address' => ['nullable', 'array'],
+ 'billing_address.first_name' => ['required_with:billing_address', 'string', 'max:255'],
+ 'billing_address.last_name' => ['required_with:billing_address', 'string', 'max:255'],
+ 'billing_address.address1' => ['required_with:billing_address', 'string', 'max:500'],
+ 'billing_address.address2' => ['nullable', 'string', 'max:500'],
+ 'billing_address.company' => ['nullable', 'string', 'max:255'],
+ 'billing_address.city' => ['required_with:billing_address', 'string', 'max:255'],
+ 'billing_address.province' => ['nullable', 'string', 'max:255'],
+ 'billing_address.province_code' => ['nullable', 'string', 'max:10'],
+ 'billing_address.country' => ['required_with:billing_address', 'string', 'max:255'],
+ 'billing_address.country_code' => ['required_with:billing_address', 'string', 'size:2', 'alpha'],
+ 'billing_address.postal_code' => ['required_with:billing_address', 'string', 'max:20'],
+ 'billing_address.phone' => ['nullable', 'string', 'max:50'],
+ 'use_shipping_as_billing' => ['sometimes', 'boolean'],
+ ];
+ }
+}
diff --git a/app/Http/Resources/Storefront/CartResource.php b/app/Http/Resources/Storefront/CartResource.php
new file mode 100644
index 00000000..4406209e
--- /dev/null
+++ b/app/Http/Resources/Storefront/CartResource.php
@@ -0,0 +1,67 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ $this->resource->loadMissing(['lines.variant.product.media', 'lines.variant.inventoryItem']);
+
+ return [
+ 'id' => $this->id,
+ 'store_id' => $this->store_id,
+ 'customer_id' => $this->customer_id,
+ 'currency' => $this->currency,
+ 'cart_version' => $this->cart_version,
+ 'status' => $this->status->value,
+ 'lines' => $this->lines->map(fn (CartLine $line): array => [
+ 'id' => $line->id,
+ 'variant_id' => $line->variant_id,
+ 'product_title' => $line->variant?->product?->title,
+ 'variant_title' => $line->variant?->title(),
+ 'sku' => $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,
+ 'image_url' => $line->variant?->product?->media->first()?->url(),
+ 'requires_shipping' => (bool) ($line->variant?->requires_shipping ?? false),
+ 'available_quantity' => $line->variant?->availableQuantity() ?? 0,
+ ])->all(),
+ 'totals' => [
+ 'subtotal' => $this->subtotal(),
+ 'discount' => (int) $this->lines->sum('line_discount_amount'),
+ 'total' => (int) $this->lines->sum('line_total_amount'),
+ 'currency' => $this->currency,
+ 'line_count' => $this->lineCount(),
+ 'item_count' => $this->itemCount(),
+ ],
+ 'created_at' => $this->created_at?->toIso8601ZuluString(),
+ 'updated_at' => $this->updated_at?->toIso8601ZuluString(),
+ ];
+ }
+}
diff --git a/app/Http/Resources/Storefront/CheckoutResource.php b/app/Http/Resources/Storefront/CheckoutResource.php
new file mode 100644
index 00000000..632cbad0
--- /dev/null
+++ b/app/Http/Resources/Storefront/CheckoutResource.php
@@ -0,0 +1,132 @@
+
+ */
+ public function toArray(Request $request): array
+ {
+ $this->resource->loadMissing(['cart.lines.variant.product']);
+
+ $cart = $this->cart;
+ $totals = $this->totals_json ?? [];
+
+ return [
+ 'id' => $this->id,
+ 'store_id' => $this->store_id,
+ 'cart_id' => $this->cart_id,
+ 'customer_id' => $this->customer_id,
+ 'status' => $this->status->value,
+ 'email' => $this->email,
+ 'shipping_address_json' => $this->shipping_address_json,
+ 'billing_address_json' => $this->billing_address_json,
+ 'shipping_method_id' => $this->shipping_method_id,
+ 'payment_method' => $this->payment_method?->value,
+ 'discount_code' => $this->discount_code,
+ 'lines' => $cart->lines->map(fn ($line): array => [
+ 'variant_id' => $line->variant_id,
+ 'product_title' => $line->variant?->product?->title,
+ 'variant_title' => $line->variant?->title(),
+ 'sku' => $line->variant?->sku,
+ 'quantity' => $line->quantity,
+ 'unit_price_amount' => $line->unit_price_amount,
+ 'line_total_amount' => $line->line_total_amount,
+ ])->all(),
+ 'totals' => [
+ 'subtotal' => $totals['subtotal'] ?? 0,
+ 'discount' => $totals['discount'] ?? 0,
+ 'shipping' => $totals['shipping'] ?? 0,
+ 'tax' => $totals['tax'] ?? 0,
+ 'total' => $totals['total'] ?? 0,
+ 'currency' => $totals['currency'] ?? $cart->currency,
+ ],
+ 'available_shipping_methods' => $this->availableShippingMethods(),
+ 'applied_discounts' => $this->appliedDiscounts($totals),
+ 'tax_provider_snapshot_json' => $this->tax_provider_snapshot_json,
+ 'expires_at' => $this->expires_at?->toIso8601ZuluString(),
+ 'created_at' => $this->created_at?->toIso8601ZuluString(),
+ ];
+ }
+
+ /**
+ * Rates available for the checkout's shipping address.
+ *
+ * @return array>
+ */
+ private function availableShippingMethods(): array
+ {
+ if (empty($this->shipping_address_json)) {
+ return [];
+ }
+
+ $methods = app(ShippingCalculator::class)->getAvailableRates(
+ $this->store,
+ Address::fromArray($this->shipping_address_json),
+ $this->cart,
+ );
+
+ return $methods->map(fn ($rate): array => [
+ 'id' => $rate->id,
+ 'name' => $rate->name,
+ 'type' => $rate->type->value,
+ 'price_amount' => $rate->amount,
+ 'currency' => $this->cart->currency,
+ 'estimated_days_min' => $rate->estimatedDaysMin,
+ 'estimated_days_max' => $rate->estimatedDaysMax,
+ ])->all();
+ }
+
+ /**
+ * The applied code discount with its calculated amount.
+ *
+ * @param array $totals
+ * @return array>
+ */
+ private function appliedDiscounts(array $totals): array
+ {
+ if ($this->discount_code === null) {
+ return [];
+ }
+
+ $discount = Discount::query()
+ ->where('store_id', $this->store_id)
+ ->whereRaw('lower(code) = ?', [mb_strtolower($this->discount_code)])
+ ->first();
+
+ if ($discount === null) {
+ return [];
+ }
+
+ return [[
+ 'code' => $discount->code,
+ 'type' => $discount->value_type->value,
+ 'value_amount' => $discount->value_amount,
+ 'applied_amount' => $totals['discount'] ?? 0,
+ 'description' => null,
+ ]];
+ }
+}
diff --git a/app/Jobs/CleanupAbandonedCarts.php b/app/Jobs/CleanupAbandonedCarts.php
new file mode 100644
index 00000000..1e4e57a2
--- /dev/null
+++ b/app/Jobs/CleanupAbandonedCarts.php
@@ -0,0 +1,48 @@
+where('status', CartStatus::Active->value)
+ ->where('updated_at', '<', now()->subDay())
+ ->with('store.settings')
+ ->chunkById(100, function ($carts) use ($checkoutService): void {
+ foreach ($carts as $cart) {
+ $thresholdDays = (int) ($cart->store->settings?->settings_json['cart_abandon_days'] ?? 14);
+
+ if ($cart->updated_at->gte(now()->subDays($thresholdDays))) {
+ continue;
+ }
+
+ foreach ($cart->checkouts()->whereNotIn('status', ['completed', 'expired'])->get() as $checkout) {
+ $checkoutService->expireCheckout($checkout);
+ }
+
+ $cart->update(['status' => CartStatus::Abandoned]);
+ }
+ });
+ }
+}
diff --git a/app/Jobs/ExpireAbandonedCheckouts.php b/app/Jobs/ExpireAbandonedCheckouts.php
new file mode 100644
index 00000000..9fe1268a
--- /dev/null
+++ b/app/Jobs/ExpireAbandonedCheckouts.php
@@ -0,0 +1,38 @@
+whereNotIn('status', [CheckoutStatus::Completed->value, CheckoutStatus::Expired->value])
+ ->where(function ($query): void {
+ $query->where('expires_at', '<', now())
+ ->orWhere('updated_at', '<', now()->subHours(24));
+ })
+ ->chunkById(100, function ($checkouts) use ($checkoutService): void {
+ foreach ($checkouts as $checkout) {
+ $checkoutService->expireCheckout($checkout);
+ }
+ });
+ }
+}
diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php
new file mode 100644
index 00000000..ef4de4f7
--- /dev/null
+++ b/app/Livewire/Storefront/Cart/Show.php
@@ -0,0 +1,40 @@
+redirectRoute('storefront.checkout.show', ['checkoutId' => 'new']);
+ }
+
+ /**
+ * Render the cart page.
+ */
+ public function render(): View
+ {
+ $cart = $this->sessionCart();
+ $cart?->loadMissing(['lines.variant.product.media', 'lines.variant.optionValues.option']);
+
+ return view('livewire.storefront.cart.show', [
+ 'cart' => $cart,
+ 'discount' => $cart !== null ? $this->appliedSessionDiscount($cart) : null,
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('Your Cart - '.app('current_store')->name);
+ }
+}
diff --git a/app/Livewire/Storefront/CartDrawer.php b/app/Livewire/Storefront/CartDrawer.php
new file mode 100644
index 00000000..ecfd2b00
--- /dev/null
+++ b/app/Livewire/Storefront/CartDrawer.php
@@ -0,0 +1,72 @@
+getOrCreateForSession($this->currentStore(), auth('customer')->user());
+
+ try {
+ $carts->addLine($cart, $variantId, $quantity);
+ } catch (InsufficientInventoryException|ValidationException) {
+ return;
+ }
+
+ $this->broadcastCartCount($cart->refresh());
+ $this->dispatch('cart-drawer-open');
+ }
+
+ /**
+ * Re-render when another component changed the cart.
+ */
+ #[On('cart-updated')]
+ public function refreshCart(): void
+ {
+ // The render cycle reloads the session cart.
+ }
+
+ /**
+ * Proceed to checkout (step 1 collects contact and address).
+ */
+ public function checkout()
+ {
+ return $this->redirectRoute('storefront.checkout.show', ['checkoutId' => 'new']);
+ }
+
+ /**
+ * Render the drawer content.
+ */
+ public function render(): View
+ {
+ $cart = $this->sessionCart();
+ $cart?->loadMissing(['lines.variant.product.media', 'lines.variant.optionValues.option']);
+
+ return view('livewire.storefront.cart-drawer', [
+ 'cart' => $cart,
+ 'discount' => $cart !== null ? $this->appliedSessionDiscount($cart) : null,
+ ]);
+ }
+}
diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php
new file mode 100644
index 00000000..eb03990e
--- /dev/null
+++ b/app/Livewire/Storefront/Checkout/Show.php
@@ -0,0 +1,308 @@
+
+ */
+ public array $address = [
+ 'first_name' => '',
+ 'last_name' => '',
+ 'company' => '',
+ 'address1' => '',
+ 'address2' => '',
+ 'city' => '',
+ 'province' => '',
+ 'province_code' => '',
+ 'country' => '',
+ 'country_code' => '',
+ 'postal_code' => '',
+ 'phone' => '',
+ ];
+
+ public bool $useShippingAsBilling = true;
+
+ public string $paymentMethod = 'credit_card';
+
+ public bool $paymentSelected = false;
+
+ public string $discountCode = '';
+
+ public ?string $discountError = null;
+
+ /**
+ * Load an existing checkout, or start fresh for "new".
+ */
+ public function mount(string $checkoutId): void
+ {
+ if ($checkoutId === 'new') {
+ $cart = app(CartService::class)->findForSession(app('current_store'));
+
+ if ($cart === null || $cart->lines->isEmpty()) {
+ $this->redirectRoute('storefront.cart.show');
+
+ return;
+ }
+
+ return;
+ }
+
+ $checkout = Checkout::find((int) $checkoutId);
+
+ abort_if($checkout === null, 404);
+
+ if ($checkout->isExpired()) {
+ $this->expired = true;
+
+ return;
+ }
+
+ $this->checkoutDbId = $checkout->id;
+ $this->email = $checkout->email ?? '';
+
+ if (! empty($checkout->shipping_address_json)) {
+ $this->address = array_merge(
+ $this->address,
+ array_filter($checkout->shipping_address_json, fn ($value) => $value !== null),
+ );
+ }
+
+ $this->step = match ($checkout->status) {
+ CheckoutStatus::Started => 1,
+ CheckoutStatus::Addressed => 2,
+ default => 3,
+ };
+
+ if ($checkout->payment_method !== null) {
+ $this->paymentMethod = $checkout->payment_method->value;
+ $this->paymentSelected = true;
+ }
+ }
+
+ /**
+ * Step 1 submit: create the checkout (when new) and set the address.
+ */
+ public function submitAddress(): void
+ {
+ // The form collects the ISO country code only; the address payload
+ // carries both representations (spec 02 §2.2).
+ $this->address['country'] = $this->address['country_code'];
+ $this->address['country_code'] = strtoupper($this->address['country_code']);
+ $this->address['country'] = strtoupper($this->address['country']);
+
+ $this->validate($this->addressRules());
+
+ $service = app(CheckoutService::class);
+
+ try {
+ if ($this->checkoutDbId === null) {
+ $cart = app(CartService::class)->findForSession(app('current_store'));
+
+ if ($cart === null || $cart->lines->isEmpty()) {
+ $this->addError('email', 'Your cart is empty.');
+
+ return;
+ }
+
+ $checkout = $service->createFromCart(
+ $cart,
+ $this->email,
+ auth('customer')->user(),
+ session('discount_code'),
+ );
+ } else {
+ $checkout = Checkout::findOrFail($this->checkoutDbId);
+ }
+
+ $service->setAddress($checkout, [
+ 'email' => $this->email,
+ 'shipping_address' => $this->address,
+ 'use_shipping_as_billing' => $this->useShippingAsBilling,
+ ]);
+ } catch (ValidationException $exception) {
+ foreach ($exception->errors() as $key => $messages) {
+ $this->addError(str_replace('shipping_address', 'address', $key), $messages[0]);
+ }
+
+ return;
+ }
+
+ session()->forget('discount_code');
+
+ $this->redirectRoute('storefront.checkout.show', ['checkoutId' => $checkout->id]);
+ }
+
+ /**
+ * Step 2: pick a shipping method and advance to payment.
+ */
+ public function selectShipping(int $rateId): void
+ {
+ $checkout = Checkout::findOrFail($this->checkoutDbId);
+
+ try {
+ app(CheckoutService::class)->setShippingMethod($checkout, $rateId);
+ } catch (ValidationException) {
+ $this->addError('shippingMethodId', 'The selected shipping method is not available for your address.');
+
+ return;
+ }
+
+ $this->step = 3;
+ }
+
+ /**
+ * Step 2 shortcut for carts without shippable items.
+ */
+ public function continueWithoutShipping(): void
+ {
+ $checkout = Checkout::findOrFail($this->checkoutDbId);
+
+ app(CheckoutService::class)->setShippingMethod($checkout, null);
+
+ $this->step = 3;
+ }
+
+ /**
+ * Step 3: record the payment method (reserves inventory). The actual
+ * payment processing ships in Phase 5.
+ */
+ public function selectPayment(): void
+ {
+ $this->validate([
+ 'paymentMethod' => ['required', 'in:credit_card,paypal,bank_transfer'],
+ ]);
+
+ $checkout = Checkout::findOrFail($this->checkoutDbId);
+
+ app(CheckoutService::class)->selectPaymentMethod($checkout, $this->paymentMethod);
+
+ $this->paymentSelected = true;
+ }
+
+ /**
+ * Apply a discount code to the checkout.
+ */
+ public function applyDiscount(): void
+ {
+ $this->discountError = null;
+ $code = trim($this->discountCode);
+
+ if ($this->checkoutDbId === null || $code === '') {
+ return;
+ }
+
+ $result = app(CheckoutService::class)->applyDiscount(
+ Checkout::findOrFail($this->checkoutDbId),
+ $code,
+ );
+
+ if (! $result->valid) {
+ $this->discountError = $result->errorMessage;
+
+ return;
+ }
+
+ $this->discountCode = '';
+ }
+
+ /**
+ * Remove the discount code from the checkout.
+ */
+ public function removeDiscount(): void
+ {
+ if ($this->checkoutDbId === null) {
+ return;
+ }
+
+ app(CheckoutService::class)->removeDiscount(Checkout::findOrFail($this->checkoutDbId));
+ }
+
+ /**
+ * Validation rules for the step 1 form (mirrors SetCheckoutAddressRequest).
+ *
+ * @return array>
+ */
+ private function addressRules(): array
+ {
+ return [
+ 'email' => ['required', 'email', 'max:255'],
+ 'address.first_name' => ['required', 'string', 'max:255'],
+ 'address.last_name' => ['required', 'string', 'max:255'],
+ 'address.company' => ['nullable', 'string', 'max:255'],
+ 'address.address1' => ['required', 'string', 'max:500'],
+ 'address.address2' => ['nullable', 'string', 'max:500'],
+ 'address.city' => ['required', 'string', 'max:255'],
+ 'address.province' => ['nullable', 'string', 'max:255'],
+ 'address.province_code' => ['nullable', 'string', 'max:10'],
+ 'address.country' => ['required', 'string', 'max:255'],
+ 'address.country_code' => ['required', 'string', 'size:2', 'alpha'],
+ 'address.postal_code' => ['required', 'string', 'max:20'],
+ 'address.phone' => ['nullable', 'string', 'max:50'],
+ 'useShippingAsBilling' => ['boolean'],
+ ];
+ }
+
+ /**
+ * Render the checkout page.
+ */
+ public function render(): View
+ {
+ $checkout = $this->checkoutDbId !== null
+ ? Checkout::with(['cart.lines.variant.product.media', 'cart.lines.variant.optionValues.option'])->find($this->checkoutDbId)
+ : null;
+
+ $rates = collect();
+
+ if ($checkout !== null && $checkout->status === CheckoutStatus::Addressed && $checkout->requiresShipping()) {
+ $rates = app(ShippingCalculator::class)->getAvailableRates(
+ $checkout->store,
+ Address::fromArray($checkout->shipping_address_json),
+ $checkout->cart,
+ );
+ }
+
+ // Preview cart for the "new" step (before the checkout exists).
+ $previewCart = $checkout === null
+ ? app(CartService::class)->findForSession(app('current_store'))
+ : null;
+
+ return view('livewire.storefront.checkout.show', [
+ 'checkout' => $checkout,
+ 'previewCart' => $previewCart,
+ 'rates' => $rates,
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('Checkout - '.app('current_store')->name);
+ }
+}
diff --git a/app/Livewire/Storefront/Concerns/InteractsWithCart.php b/app/Livewire/Storefront/Concerns/InteractsWithCart.php
new file mode 100644
index 00000000..85a9d481
--- /dev/null
+++ b/app/Livewire/Storefront/Concerns/InteractsWithCart.php
@@ -0,0 +1,194 @@
+changeLineQuantity($lineId, 1);
+ }
+
+ /**
+ * Decrement a line's quantity by one (removes the line at zero).
+ */
+ public function decrementLine(int $lineId): void
+ {
+ $this->changeLineQuantity($lineId, -1);
+ }
+
+ /**
+ * Remove a line from the session cart.
+ */
+ public function removeLine(int $lineId): void
+ {
+ $cart = $this->sessionCart();
+
+ if ($cart === null) {
+ return;
+ }
+
+ app(CartService::class)->removeLine($cart, $lineId);
+ $this->broadcastCartCount($cart->refresh());
+ }
+
+ /**
+ * Validate the entered discount code and store it in the session.
+ */
+ public function applyDiscount(): void
+ {
+ $this->discountError = null;
+ $cart = $this->sessionCart();
+ $code = trim($this->discountCode);
+
+ if ($cart === null || $code === '') {
+ return;
+ }
+
+ $result = app(DiscountService::class)->validate($code, $this->currentStore(), $cart);
+
+ if (! $result->valid) {
+ $this->discountError = $result->errorMessage;
+
+ return;
+ }
+
+ session(['discount_code' => $result->discount->code]);
+ $this->discountCode = '';
+ }
+
+ /**
+ * Remove the session discount code.
+ */
+ public function removeDiscount(): void
+ {
+ session()->forget('discount_code');
+ $this->discountError = null;
+ }
+
+ /**
+ * Change a line quantity by a delta, ignoring stock rejections.
+ */
+ private function changeLineQuantity(int $lineId, int $delta): void
+ {
+ $cart = $this->sessionCart();
+ $line = $cart?->lines->firstWhere('id', $lineId);
+
+ if ($cart === null || $line === null) {
+ return;
+ }
+
+ try {
+ app(CartService::class)->updateLineQuantity($cart, $lineId, $line->quantity + $delta);
+ } catch (InsufficientInventoryException|ValidationException) {
+ return;
+ }
+
+ $this->broadcastCartCount($cart->refresh());
+ }
+
+ /**
+ * The active cart bound to the session (not created on demand).
+ */
+ protected function sessionCart(): ?Cart
+ {
+ return app(CartService::class)->findForSession($this->currentStore());
+ }
+
+ /**
+ * The store resolved for the current request.
+ */
+ protected function currentStore(): Store
+ {
+ return app('current_store');
+ }
+
+ /**
+ * Tell the header badge the new item count.
+ */
+ protected function broadcastCartCount(Cart $cart): void
+ {
+ $this->dispatch('cart-updated', count: $cart->itemCount());
+ }
+
+ /**
+ * The validated session discount with its calculated amount, if any.
+ *
+ * @return array{code: string, label: string, amount: int, free_shipping: bool}|null
+ */
+ protected function appliedSessionDiscount(Cart $cart): ?array
+ {
+ $code = session('discount_code');
+
+ if ($code === null) {
+ return null;
+ }
+
+ $discount = Discount::query()
+ ->where('store_id', $this->currentStore()->id)
+ ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
+ ->first();
+
+ if ($discount === null) {
+ session()->forget('discount_code');
+
+ return null;
+ }
+
+ $result = app(DiscountService::class)->calculate(
+ $discount,
+ $cart->subtotal(),
+ $this->calculationLines($cart),
+ );
+
+ $label = match ($discount->value_type) {
+ \App\Enums\DiscountValueType::Percent => "-{$discount->value_amount}%",
+ \App\Enums\DiscountValueType::Fixed => '-'.\App\Support\Money::format($result['amount'], $cart->currency),
+ \App\Enums\DiscountValueType::FreeShipping => 'Free shipping',
+ };
+
+ return [
+ 'code' => $discount->code,
+ 'label' => $label,
+ 'amount' => $result['amount'],
+ 'free_shipping' => $result['free_shipping'],
+ ];
+ }
+
+ /**
+ * Flat calculation representation of the cart lines for discounts.
+ *
+ * @return array>
+ */
+ protected function calculationLines(Cart $cart): array
+ {
+ $cart->loadMissing('lines.variant.product.collections');
+
+ return $cart->lines->values()->map(fn ($line): array => [
+ 'variant_id' => $line->variant_id,
+ 'product_id' => $line->variant?->product_id,
+ 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [],
+ 'line_subtotal_amount' => $line->line_subtotal_amount,
+ ])->all();
+ }
+}
diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php
index 12744f36..31022108 100644
--- a/app/Livewire/Storefront/Products/Show.php
+++ b/app/Livewire/Storefront/Products/Show.php
@@ -132,9 +132,8 @@ public function selectOption(string $optionName, string $value): void
}
/**
- * Stage the add-to-cart for the selected variant. The real cart is
- * wired in Phase 4; for now we dispatch the browser event it will
- * listen to.
+ * Add the selected variant to the session cart, update the header
+ * badge and open the cart drawer.
*/
public function addToCart(): void
{
@@ -144,7 +143,17 @@ public function addToCart(): void
return;
}
- $this->dispatch('add-to-cart', variantId: $variant->id, quantity: max(1, $this->quantity));
+ $carts = app(\App\Services\CartService::class);
+ $cart = $carts->getOrCreateForSession(app('current_store'), auth('customer')->user());
+
+ try {
+ $carts->addLine($cart, $variant->id, max(1, $this->quantity));
+ } catch (\App\Exceptions\InsufficientInventoryException|\Illuminate\Validation\ValidationException) {
+ return;
+ }
+
+ $this->dispatch('cart-updated', count: $cart->refresh()->itemCount());
+ $this->dispatch('cart-drawer-open');
}
/**
diff --git a/app/Models/Cart.php b/app/Models/Cart.php
new file mode 100644
index 00000000..061253cb
--- /dev/null
+++ b/app/Models/Cart.php
@@ -0,0 +1,122 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'customer_id',
+ 'currency',
+ 'cart_version',
+ 'status',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => CartStatus::class,
+ 'cart_version' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the customer that owns the cart.
+ *
+ * @return BelongsTo
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Customer::class);
+ }
+
+ /**
+ * Get the lines in the cart.
+ *
+ * @return HasMany
+ */
+ public function lines(): HasMany
+ {
+ return $this->hasMany(CartLine::class);
+ }
+
+ /**
+ * Get the checkouts created from the cart.
+ *
+ * @return HasMany
+ */
+ public function checkouts(): HasMany
+ {
+ return $this->hasMany(Checkout::class);
+ }
+
+ /**
+ * Sum of all line subtotals (before discounts), in minor units.
+ */
+ public function subtotal(): int
+ {
+ return (int) $this->lines->sum('line_subtotal_amount');
+ }
+
+ /**
+ * Total number of units across all lines.
+ */
+ public function itemCount(): int
+ {
+ return (int) $this->lines->sum('quantity');
+ }
+
+ /**
+ * Number of distinct lines in the cart.
+ */
+ public function lineCount(): int
+ {
+ return $this->lines->count();
+ }
+
+ /**
+ * Find a line by variant ID.
+ */
+ public function findLineByVariant(int $variantId): ?CartLine
+ {
+ return $this->lines->firstWhere('variant_id', $variantId);
+ }
+
+ /**
+ * Recalculate subtotal/total amounts on every line.
+ */
+ public function recalculateLines(): void
+ {
+ $this->lines->each->recalculate();
+ }
+
+ /**
+ * Whether any line in the cart requires physical shipping.
+ */
+ public function requiresShipping(): bool
+ {
+ return $this->lines->contains(
+ fn (CartLine $line): bool => (bool) $line->variant?->requires_shipping
+ );
+ }
+}
diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php
new file mode 100644
index 00000000..288f1aca
--- /dev/null
+++ b/app/Models/CartLine.php
@@ -0,0 +1,81 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'cart_id',
+ 'variant_id',
+ 'quantity',
+ 'unit_price_amount',
+ 'line_subtotal_amount',
+ 'line_discount_amount',
+ 'line_total_amount',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'quantity' => 'integer',
+ 'unit_price_amount' => 'integer',
+ 'line_subtotal_amount' => 'integer',
+ 'line_discount_amount' => 'integer',
+ 'line_total_amount' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the cart that owns the line.
+ *
+ * @return BelongsTo
+ */
+ public function cart(): BelongsTo
+ {
+ return $this->belongsTo(Cart::class);
+ }
+
+ /**
+ * Get the variant being purchased.
+ *
+ * @return BelongsTo
+ */
+ public function variant(): BelongsTo
+ {
+ return $this->belongsTo(ProductVariant::class, 'variant_id');
+ }
+
+ /**
+ * Recalculate the line amounts from unit price, quantity and discount.
+ */
+ public function recalculate(): void
+ {
+ $this->line_subtotal_amount = $this->unit_price_amount * $this->quantity;
+ $this->line_total_amount = $this->line_subtotal_amount - $this->line_discount_amount;
+ $this->save();
+ }
+}
diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php
new file mode 100644
index 00000000..a33647c1
--- /dev/null
+++ b/app/Models/Checkout.php
@@ -0,0 +1,99 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'cart_id',
+ 'customer_id',
+ 'status',
+ 'payment_method',
+ 'email',
+ 'shipping_address_json',
+ 'billing_address_json',
+ 'shipping_method_id',
+ 'discount_code',
+ 'tax_provider_snapshot_json',
+ 'totals_json',
+ 'expires_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => CheckoutStatus::class,
+ 'payment_method' => PaymentMethod::class,
+ 'shipping_address_json' => 'array',
+ 'billing_address_json' => 'array',
+ 'tax_provider_snapshot_json' => 'array',
+ 'totals_json' => 'array',
+ 'expires_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Get the cart backing the checkout.
+ *
+ * @return BelongsTo
+ */
+ public function cart(): BelongsTo
+ {
+ return $this->belongsTo(Cart::class);
+ }
+
+ /**
+ * Get the customer that owns the checkout.
+ *
+ * @return BelongsTo
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Customer::class);
+ }
+
+ /**
+ * Whether any cart line requires physical shipping.
+ */
+ public function requiresShipping(): bool
+ {
+ return $this->cart->requiresShipping();
+ }
+
+ /**
+ * Whether the checkout is expired (either transitioned or past its deadline).
+ */
+ public function isExpired(): bool
+ {
+ if ($this->status === CheckoutStatus::Expired) {
+ return true;
+ }
+
+ if ($this->status === CheckoutStatus::Completed) {
+ return false;
+ }
+
+ return $this->expires_at !== null && $this->expires_at->isPast();
+ }
+}
diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php
index e863bbef..5a405dea 100644
--- a/app/Models/Concerns/BelongsToStore.php
+++ b/app/Models/Concerns/BelongsToStore.php
@@ -3,7 +3,9 @@
namespace App\Models\Concerns;
use App\Models\Scopes\StoreScope;
+use App\Models\Store;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
trait BelongsToStore
{
@@ -21,4 +23,14 @@ public static function bootBelongsToStore(): void
}
});
}
+
+ /**
+ * Get the store that owns the model.
+ *
+ * @return BelongsTo
+ */
+ public function store(): BelongsTo
+ {
+ return $this->belongsTo(Store::class);
+ }
}
diff --git a/app/Models/Discount.php b/app/Models/Discount.php
new file mode 100644
index 00000000..cf55e8e9
--- /dev/null
+++ b/app/Models/Discount.php
@@ -0,0 +1,66 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'type',
+ 'code',
+ 'value_type',
+ 'value_amount',
+ 'starts_at',
+ 'ends_at',
+ 'usage_limit',
+ 'usage_count',
+ 'rules_json',
+ 'status',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => DiscountType::class,
+ 'value_type' => DiscountValueType::class,
+ 'status' => DiscountStatus::class,
+ 'rules_json' => 'array',
+ 'starts_at' => 'datetime',
+ 'ends_at' => 'datetime',
+ 'value_amount' => 'integer',
+ 'usage_limit' => 'integer',
+ 'usage_count' => 'integer',
+ ];
+ }
+
+ /**
+ * Scope to active discounts.
+ *
+ * @param Builder $query
+ */
+ protected function scopeActive(Builder $query): void
+ {
+ $query->where('status', DiscountStatus::Active);
+ }
+}
diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php
new file mode 100644
index 00000000..8d19d10e
--- /dev/null
+++ b/app/Models/ShippingRate.php
@@ -0,0 +1,58 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'zone_id',
+ 'name',
+ 'type',
+ 'config_json',
+ 'is_active',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'type' => ShippingRateType::class,
+ 'config_json' => 'array',
+ 'is_active' => 'boolean',
+ ];
+ }
+
+ /**
+ * Get the zone that owns the rate.
+ *
+ * @return BelongsTo
+ */
+ public function zone(): BelongsTo
+ {
+ return $this->belongsTo(ShippingZone::class, 'zone_id');
+ }
+}
diff --git a/app/Models/ShippingZone.php b/app/Models/ShippingZone.php
new file mode 100644
index 00000000..4d1ab8a2
--- /dev/null
+++ b/app/Models/ShippingZone.php
@@ -0,0 +1,56 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'name',
+ 'countries_json',
+ 'regions_json',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'countries_json' => 'array',
+ 'regions_json' => 'array',
+ ];
+ }
+
+ /**
+ * Get the rates within the zone.
+ *
+ * @return HasMany
+ */
+ public function rates(): HasMany
+ {
+ return $this->hasMany(ShippingRate::class, 'zone_id');
+ }
+}
diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php
new file mode 100644
index 00000000..3d76386b
--- /dev/null
+++ b/app/Models/TaxSettings.php
@@ -0,0 +1,72 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The primary key is the store ID (one settings row per store).
+ *
+ * @var string
+ */
+ protected $primaryKey = 'store_id';
+
+ /**
+ * The primary key is not auto-incrementing.
+ *
+ * @var bool
+ */
+ public $incrementing = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'mode',
+ 'provider',
+ 'prices_include_tax',
+ 'config_json',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'mode' => TaxMode::class,
+ 'prices_include_tax' => 'boolean',
+ 'config_json' => 'array',
+ ];
+ }
+
+ /**
+ * Get the store these settings belong to.
+ *
+ * @return BelongsTo
+ */
+ public function store(): BelongsTo
+ {
+ return $this->belongsTo(Store::class);
+ }
+}
diff --git a/app/Services/CartService.php b/app/Services/CartService.php
new file mode 100644
index 00000000..1ddb2eeb
--- /dev/null
+++ b/app/Services/CartService.php
@@ -0,0 +1,240 @@
+ $store->id,
+ 'customer_id' => $customer?->id,
+ 'currency' => $store->default_currency,
+ 'cart_version' => 1,
+ 'status' => CartStatus::Active,
+ ]);
+ }
+
+ /**
+ * Add a variant to the cart. Existing lines for the variant are
+ * incremented instead of duplicated (spec 05 §4.2).
+ *
+ * @throws ValidationException invalid variant / inactive product
+ * @throws InsufficientInventoryException policy "deny" and out of stock
+ */
+ public function addLine(Cart $cart, int $variantId, int $quantity): CartLine
+ {
+ $variant = ProductVariant::query()
+ ->whereKey($variantId)
+ ->whereHas('product', fn ($query) => $query->where('store_id', $cart->store_id))
+ ->with(['product', 'inventoryItem'])
+ ->first();
+
+ if ($variant === null) {
+ throw ValidationException::withMessages([
+ 'variant_id' => ['The selected variant is invalid.'],
+ ]);
+ }
+
+ if ($variant->product->status !== ProductStatus::Active) {
+ throw ValidationException::withMessages([
+ 'variant_id' => ['The selected product is not available.'],
+ ]);
+ }
+
+ if ($variant->status !== VariantStatus::Active) {
+ throw ValidationException::withMessages([
+ 'variant_id' => ['The selected variant is not available.'],
+ ]);
+ }
+
+ $line = $cart->lines()->where('variant_id', $variantId)->first();
+ $newQuantity = ($line?->quantity ?? 0) + $quantity;
+
+ // The merged quantity is what will be reserved at checkout, so the
+ // inventory policy is checked against it.
+ $item = $variant->inventoryItem;
+
+ if ($item !== null && ! $this->inventory->checkAvailability($item, $newQuantity)) {
+ throw InsufficientInventoryException::forReservation($item, $newQuantity);
+ }
+
+ if ($line !== null) {
+ $line->quantity = $newQuantity;
+ $line->recalculate();
+ } else {
+ $line = new CartLine([
+ 'variant_id' => $variant->id,
+ 'quantity' => $quantity,
+ 'unit_price_amount' => $variant->price_amount,
+ 'line_discount_amount' => 0,
+ ]);
+ $line->cart()->associate($cart);
+ $line->recalculate();
+ }
+
+ $this->touchVersion($cart);
+
+ return $line;
+ }
+
+ /**
+ * Update a line's quantity. Setting 0 removes the line.
+ *
+ * @throws InsufficientInventoryException policy "deny" and out of stock
+ */
+ public function updateLineQuantity(Cart $cart, int $lineId, int $quantity): CartLine
+ {
+ if ($quantity < 0) {
+ throw ValidationException::withMessages([
+ 'quantity' => ['The quantity must not be negative.'],
+ ]);
+ }
+
+ $line = $cart->lines()->findOrFail($lineId);
+
+ if ($quantity === 0) {
+ $line->delete();
+ $this->touchVersion($cart);
+
+ return $line;
+ }
+
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null && ! $this->inventory->checkAvailability($item, $quantity)) {
+ throw InsufficientInventoryException::forReservation($item, $quantity);
+ }
+
+ $line->quantity = $quantity;
+ $line->recalculate();
+
+ $this->touchVersion($cart);
+
+ return $line;
+ }
+
+ /**
+ * Remove a line from the cart.
+ */
+ public function removeLine(Cart $cart, int $lineId): void
+ {
+ $cart->lines()->findOrFail($lineId)->delete();
+
+ $this->touchVersion($cart);
+ }
+
+ /**
+ * The active cart bound to the current session, or null when the
+ * visitor has no cart yet (does not create one).
+ */
+ public function findForSession(Store $store): ?Cart
+ {
+ $cartId = session('cart_id');
+
+ if ($cartId === null) {
+ return null;
+ }
+
+ return Cart::query()
+ ->where('store_id', $store->id)
+ ->where('status', CartStatus::Active)
+ ->find($cartId);
+ }
+
+ /**
+ * The active cart for the current session, creating and binding one on
+ * first use (spec 05 §4.1 guest identification).
+ */
+ public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart
+ {
+ $cart = $this->findForSession($store);
+
+ if ($cart === null) {
+ $cart = $this->create($store, $customer);
+ session(['cart_id' => $cart->id]);
+ } elseif ($customer !== null && $cart->customer_id === null) {
+ $cart->update(['customer_id' => $customer->id]);
+ }
+
+ return $cart;
+ }
+
+ /**
+ * Merge a guest cart into a customer cart on login: duplicate variants
+ * keep the combined quantity, the guest cart is abandoned and the
+ * session key is cleared (spec 05 §4.1; the spec 09 test tables require
+ * summed quantities, which overrides the MAX() in the §4.1 pseudocode).
+ */
+ public function mergeOnLogin(Cart $guest, Cart $customer): Cart
+ {
+ $guest->loadMissing('lines');
+ $customer->loadMissing('lines');
+
+ foreach ($guest->lines as $line) {
+ $existing = $customer->findLineByVariant($line->variant_id);
+
+ if ($existing !== null) {
+ $existing->quantity += $line->quantity;
+ $existing->recalculate();
+ } else {
+ $line->cart()->associate($customer);
+ $line->save();
+ }
+ }
+
+ $guest->update(['status' => CartStatus::Abandoned]);
+
+ $customer->unsetRelation('lines');
+ $customer->load('lines');
+ $customer->recalculateLines();
+
+ $this->touchVersion($customer);
+
+ session()->forget('cart_id');
+
+ return $customer->refresh();
+ }
+
+ /**
+ * Verify the client's expected version against the current version.
+ *
+ * @throws CartVersionMismatchException
+ */
+ public function assertVersion(Cart $cart, int $expectedVersion): void
+ {
+ if ($cart->cart_version !== $expectedVersion) {
+ throw new CartVersionMismatchException($cart);
+ }
+ }
+
+ /**
+ * Increment the cart version and keep the in-memory model in sync.
+ */
+ private function touchVersion(Cart $cart): void
+ {
+ $cart->increment('cart_version');
+ $cart->refresh();
+ }
+}
diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php
new file mode 100644
index 00000000..4d0b0300
--- /dev/null
+++ b/app/Services/CheckoutService.php
@@ -0,0 +1,409 @@
+ addressed -> shipping_selected -> payment_selected -> completed.
+ * Any active state can transition to expired. Pricing is recalculated on
+ * every significant state change and snapshotted to checkouts.totals_json.
+ *
+ * The final `payment_selected -> completed` transition (completeCheckout)
+ * is implemented in Phase 5 together with payments and orders.
+ */
+class CheckoutService
+{
+ public function __construct(
+ private PricingEngine $pricingEngine,
+ private DiscountService $discounts,
+ private ShippingCalculator $shipping,
+ private TaxCalculator $taxCalculator,
+ private InventoryService $inventory,
+ ) {}
+
+ /**
+ * Create a checkout from an active cart with at least one line.
+ *
+ * @throws ValidationException empty cart or inactive cart
+ */
+ public function createFromCart(Cart $cart, string $email, ?Customer $customer = null, ?string $discountCode = null): Checkout
+ {
+ $cart->loadMissing('lines');
+
+ if ($cart->status !== CartStatus::Active) {
+ throw ValidationException::withMessages([
+ 'cart_id' => ['The cart is not active.'],
+ ]);
+ }
+
+ if ($cart->lines->isEmpty()) {
+ throw ValidationException::withMessages([
+ 'cart_id' => ['The cart is empty.'],
+ ]);
+ }
+
+ $checkout = Checkout::create([
+ 'store_id' => $cart->store_id,
+ 'cart_id' => $cart->id,
+ 'customer_id' => $customer?->id ?? $cart->customer_id,
+ 'status' => CheckoutStatus::Started,
+ 'email' => $email,
+ 'expires_at' => now()->addHours(24),
+ ]);
+
+ $this->recalculate($checkout);
+
+ if ($discountCode !== null && $discountCode !== '') {
+ $this->applyDiscount($checkout, $discountCode);
+ }
+
+ return $checkout->refresh();
+ }
+
+ /**
+ * Transition started -> addressed: store email and addresses, verify the
+ * address is serviceable, recalculate pricing (spec 05 §6.2).
+ *
+ * @param array{email?: string, shipping_address: array, billing_address?: array|null, use_shipping_as_billing?: bool} $data
+ *
+ * @throws InvalidCheckoutTransitionException|ValidationException
+ */
+ public function setAddress(Checkout $checkout, array $data): Checkout
+ {
+ $this->assertStatus($checkout, [CheckoutStatus::Started, CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], 'setAddress');
+
+ $address = Address::fromArray($data['shipping_address']);
+
+ if ($checkout->requiresShipping()
+ && $this->shipping->getMatchingZone($checkout->store, $address) === null) {
+ throw ValidationException::withMessages([
+ 'shipping_address' => ['Cannot ship to this address.'],
+ ]);
+ }
+
+ $useShippingAsBilling = $data['use_shipping_as_billing'] ?? true;
+ $billing = ! $useShippingAsBilling && ! empty($data['billing_address'])
+ ? Address::fromArray($data['billing_address'])
+ : $address;
+
+ // Re-addressing after shipping was selected invalidates the chosen
+ // rate (the new address may fall into a different zone).
+ $checkout->fill([
+ 'email' => $data['email'] ?? $checkout->email,
+ 'shipping_address_json' => $address->toArray(),
+ 'billing_address_json' => $billing->toArray(),
+ 'shipping_method_id' => null,
+ 'status' => CheckoutStatus::Addressed,
+ ])->save();
+
+ $this->recalculate($checkout);
+
+ CheckoutAddressed::dispatch($checkout);
+
+ return $checkout->refresh();
+ }
+
+ /**
+ * Transition addressed -> shipping_selected. Carts without shippable
+ * lines skip the step: shipping_method_id stays null and shipping is 0
+ * (spec 05 §6.2 / §9.3).
+ *
+ * @throws InvalidCheckoutTransitionException|ValidationException
+ */
+ public function setShippingMethod(Checkout $checkout, ?int $shippingRateId): Checkout
+ {
+ $this->assertStatus($checkout, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected], 'setShippingMethod');
+
+ if (! $checkout->requiresShipping()) {
+ $checkout->fill([
+ 'shipping_method_id' => null,
+ 'status' => CheckoutStatus::ShippingSelected,
+ ])->save();
+
+ $this->recalculate($checkout);
+
+ CheckoutShippingSelected::dispatch($checkout);
+
+ return $checkout->refresh();
+ }
+
+ $address = Address::fromArray($checkout->shipping_address_json ?? []);
+ $available = $this->shipping->getAvailableRates($checkout->store, $address, $checkout->cart);
+
+ if ($shippingRateId === null || ! $available->contains('id', $shippingRateId)) {
+ throw ValidationException::withMessages([
+ 'shipping_method_id' => ['The selected shipping method is not available for this address.'],
+ ]);
+ }
+
+ $checkout->fill([
+ 'shipping_method_id' => $shippingRateId,
+ 'status' => CheckoutStatus::ShippingSelected,
+ ])->save();
+
+ $this->recalculate($checkout);
+
+ CheckoutShippingSelected::dispatch($checkout);
+
+ return $checkout->refresh();
+ }
+
+ /**
+ * Transition shipping_selected -> payment_selected: store the method,
+ * reserve inventory for all lines and set the 24h expiry (spec 05 §6.2).
+ *
+ * @throws InvalidCheckoutTransitionException
+ */
+ public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $method): Checkout
+ {
+ $this->assertStatus($checkout, [CheckoutStatus::ShippingSelected], 'selectPaymentMethod');
+
+ $method = $method instanceof PaymentMethod ? $method : PaymentMethod::from($method);
+
+ DB::transaction(function () use ($checkout, $method): void {
+ $checkout->loadMissing('cart.lines.variant.inventoryItem');
+
+ foreach ($checkout->cart->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->reserve($item, $line->quantity);
+ }
+ }
+
+ $checkout->fill([
+ 'payment_method' => $method,
+ 'expires_at' => now()->addHours(24),
+ 'status' => CheckoutStatus::PaymentSelected,
+ ])->save();
+ });
+
+ return $checkout->refresh();
+ }
+
+ /**
+ * Validate and apply a discount code, then recalculate totals.
+ */
+ public function applyDiscount(Checkout $checkout, string $code): DiscountValidationResult
+ {
+ $result = $this->discounts->validate($code, $checkout->store, $checkout->cart);
+
+ if ($result->valid) {
+ $checkout->fill(['discount_code' => $result->discount->code])->save();
+ $this->recalculate($checkout);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Remove the applied discount code and recalculate totals.
+ */
+ public function removeDiscount(Checkout $checkout): void
+ {
+ $checkout->fill(['discount_code' => null])->save();
+ $this->recalculate($checkout);
+ }
+
+ /**
+ * Transition any active state -> expired, releasing reserved inventory
+ * when payment had been selected (spec 05 §6.2).
+ */
+ public function expireCheckout(Checkout $checkout): void
+ {
+ if (in_array($checkout->status, [CheckoutStatus::Completed, CheckoutStatus::Expired], true)) {
+ return;
+ }
+
+ DB::transaction(function () use ($checkout): void {
+ if ($checkout->status === CheckoutStatus::PaymentSelected) {
+ $checkout->loadMissing('cart.lines.variant.inventoryItem');
+
+ foreach ($checkout->cart->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->release($item, $line->quantity);
+ }
+ }
+ }
+
+ $checkout->fill(['status' => CheckoutStatus::Expired])->save();
+ });
+
+ CheckoutExpired::dispatch($checkout);
+ }
+
+ /**
+ * Recalculate pricing and persist the snapshot: checkouts.totals_json,
+ * per-line discount amounts on cart_lines, and the tax provider snapshot
+ * (spec 05 §5.4 / §6.2).
+ */
+ public function recalculate(Checkout $checkout): PricingResult
+ {
+ $cart = $checkout->cart()->with(['lines.variant.product.collections'])->firstOrFail();
+ $cart->loadMissing('lines.variant.product.collections');
+
+ $lines = $cart->lines->values()->map(fn ($line): array => [
+ 'variant_id' => $line->variant_id,
+ 'product_id' => $line->variant?->product_id,
+ 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [],
+ 'quantity' => $line->quantity,
+ 'unit_price_amount' => $line->unit_price_amount,
+ 'requires_shipping' => (bool) ($line->variant?->requires_shipping ?? false),
+ ])->all();
+
+ $store = $checkout->store;
+
+ $codeDiscount = $checkout->discount_code !== null
+ ? Discount::query()
+ ->where('store_id', $store->id)
+ ->whereRaw('lower(code) = ?', [mb_strtolower($checkout->discount_code)])
+ ->first()
+ : null;
+
+ $automaticDiscounts = $this->discounts->getApplicableAutomaticDiscounts($store, $cart)->all();
+
+ $shippingRate = $this->resolveShippingRate($checkout, $cart);
+
+ $taxSettings = TaxSettings::find($store->id) ?? new TaxSettings([
+ 'store_id' => $store->id,
+ 'prices_include_tax' => false,
+ 'config_json' => [],
+ ]);
+
+ $address = ! empty($checkout->shipping_address_json)
+ ? Address::fromArray($checkout->shipping_address_json)
+ : null;
+
+ $result = $this->pricingEngine->calculate(
+ lines: $lines,
+ codeDiscount: $codeDiscount,
+ automaticDiscounts: $automaticDiscounts,
+ shippingRate: $shippingRate,
+ taxSettings: $taxSettings,
+ address: $address,
+ currency: $cart->currency,
+ );
+
+ // Persist per-line discount allocations on the cart lines.
+ foreach ($cart->lines->values() as $index => $line) {
+ $discountAmount = $result->lineDiscounts[$index] ?? 0;
+
+ if ($line->line_discount_amount !== $discountAmount) {
+ $line->line_discount_amount = $discountAmount;
+ $line->recalculate();
+ }
+ }
+
+ $checkout->fill([
+ 'totals_json' => $result->toArray(),
+ 'tax_provider_snapshot_json' => $address !== null
+ ? $this->buildTaxSnapshot($lines, $result, $shippingRate, $taxSettings, $address)
+ : null,
+ ])->save();
+
+ return $result;
+ }
+
+ /**
+ * Resolve the selected shipping rate into a calculated VO, if any.
+ */
+ private function resolveShippingRate(Checkout $checkout, Cart $cart): ?ShippingRateVO
+ {
+ if ($checkout->shipping_method_id === null || ! $checkout->requiresShipping()) {
+ return null;
+ }
+
+ $rate = ShippingRate::find($checkout->shipping_method_id);
+
+ if ($rate === null) {
+ return null;
+ }
+
+ $amount = $this->shipping->calculate($rate, $cart);
+
+ if ($amount === null) {
+ return null;
+ }
+
+ $config = $rate->config_json ?? [];
+
+ return new ShippingRateVO(
+ id: $rate->id,
+ name: $rate->name,
+ amount: $amount,
+ type: $rate->type,
+ estimatedDaysMin: isset($config['estimated_days_min']) ? (int) $config['estimated_days_min'] : null,
+ estimatedDaysMax: isset($config['estimated_days_max']) ? (int) $config['estimated_days_max'] : null,
+ );
+ }
+
+ /**
+ * Build the tax_provider_snapshot_json payload (spec 02 §2.2).
+ *
+ * @param array> $lines
+ * @return array
+ */
+ private function buildTaxSnapshot(array $lines, PricingResult $result, ?ShippingRateVO $shippingRate, TaxSettings $taxSettings, Address $address): array
+ {
+ $lineItems = [];
+
+ foreach ($lines as $index => $line) {
+ $lineItems[] = [
+ 'variant_id' => $line['variant_id'] ?? null,
+ 'amount' => ($line['unit_price_amount'] * $line['quantity']) - ($result->lineDiscounts[$index] ?? 0),
+ ];
+ }
+
+ $taxResult = $this->taxCalculator->calculate(new TaxCalculationRequest(
+ lineItems: $lineItems,
+ shippingAmount: $result->shipping,
+ address: $address,
+ taxSettings: $taxSettings,
+ ));
+
+ return [
+ 'provider' => $taxSettings->mode?->value === 'provider' ? $taxSettings->provider : 'manual',
+ 'calculated_at' => now()->toIso8601ZuluString(),
+ 'lines' => $taxResult->lineDetails,
+ 'shipping_tax_amount' => $taxResult->shippingTaxAmount,
+ 'shipping_tax_rate' => $taxResult->shippingTaxRate,
+ ];
+ }
+
+ /**
+ * Guard the checkout's current status against the allowed set.
+ *
+ * @param array $allowed
+ *
+ * @throws InvalidCheckoutTransitionException
+ */
+ private function assertStatus(Checkout $checkout, array $allowed, string $transition): void
+ {
+ if (! in_array($checkout->status, $allowed, true)) {
+ throw InvalidCheckoutTransitionException::make($checkout->status->value, $transition);
+ }
+ }
+}
diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php
new file mode 100644
index 00000000..0ebda009
--- /dev/null
+++ b/app/Services/DiscountService.php
@@ -0,0 +1,205 @@
+where('store_id', $store->id)
+ ->where('type', DiscountType::Code)
+ ->whereRaw('lower(code) = ?', [mb_strtolower($code)])
+ ->first();
+
+ if ($discount === null) {
+ return DiscountValidationResult::invalid('discount_not_found', 'This discount code is invalid.');
+ }
+
+ if ($discount->status !== DiscountStatus::Active) {
+ return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.');
+ }
+
+ if ($discount->starts_at !== null && $discount->starts_at->isFuture()) {
+ return DiscountValidationResult::invalid('discount_not_yet_active', 'This discount code is not active yet.');
+ }
+
+ if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
+ return DiscountValidationResult::invalid('discount_expired', 'This discount code has expired.');
+ }
+
+ if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
+ return DiscountValidationResult::invalid('discount_usage_limit_reached', 'This discount code has reached its usage limit.');
+ }
+
+ $minPurchase = $discount->rules_json['min_purchase_amount'] ?? null;
+
+ if ($minPurchase !== null && $cart->subtotal() < (int) $minPurchase) {
+ return DiscountValidationResult::invalid('discount_min_purchase_not_met', 'The cart does not meet the minimum purchase amount for this discount.');
+ }
+
+ if ($this->qualifyingLineIndexes($discount, $this->linesForCalculation($cart)) === []) {
+ return DiscountValidationResult::invalid('discount_not_applicable', 'This discount does not apply to the items in your cart.');
+ }
+
+ return DiscountValidationResult::valid($discount);
+ }
+
+ /**
+ * Calculate the discount amount and allocate it proportionally across
+ * qualifying lines using the largest-remainder method (spec 05 §7.6):
+ * every qualifying line except the last gets ROUND(total * share), the
+ * last line gets the remainder.
+ *
+ * Percent discounts use integer truncation for the total
+ * (subtotal * value / 100), per the spec 09 test tables.
+ *
+ * @param array, line_subtotal_amount: int}> $lines
+ * @return array{amount: int, allocations: array, free_shipping: bool}
+ */
+ public function calculate(Discount $discount, int $subtotal, array $lines): array
+ {
+ if ($discount->value_type === DiscountValueType::FreeShipping) {
+ return ['amount' => 0, 'allocations' => [], 'free_shipping' => true];
+ }
+
+ $qualifying = $this->qualifyingLineIndexes($discount, $lines);
+ $qualifyingSubtotal = 0;
+
+ foreach ($qualifying as $index) {
+ $qualifyingSubtotal += $lines[$index]['line_subtotal_amount'];
+ }
+
+ if ($qualifying === [] || $qualifyingSubtotal <= 0) {
+ return ['amount' => 0, 'allocations' => [], 'free_shipping' => false];
+ }
+
+ $total = match ($discount->value_type) {
+ DiscountValueType::Percent => intdiv($qualifyingSubtotal * $discount->value_amount, 100),
+ default => min($discount->value_amount, $qualifyingSubtotal),
+ };
+
+ $allocations = [];
+ $remaining = $total;
+ $lastIndex = end($qualifying);
+
+ foreach ($qualifying as $index) {
+ if ($index === $lastIndex) {
+ $allocations[$index] = $remaining;
+ } else {
+ $lineDiscount = (int) round($total * $lines[$index]['line_subtotal_amount'] / $qualifyingSubtotal);
+ $allocations[$index] = $lineDiscount;
+ $remaining -= $lineDiscount;
+ }
+ }
+
+ return ['amount' => $total, 'allocations' => $allocations, 'free_shipping' => false];
+ }
+
+ /**
+ * Automatic discounts currently applicable to the cart, ordered by ID
+ * for deterministic stacking (spec 05 §7.1).
+ *
+ * @return Collection
+ */
+ public function getApplicableAutomaticDiscounts(Store $store, Cart $cart): Collection
+ {
+ $lines = $this->linesForCalculation($cart);
+ $subtotal = $cart->subtotal();
+
+ return Discount::query()
+ ->where('store_id', $store->id)
+ ->where('type', DiscountType::Automatic)
+ ->active()
+ ->orderBy('id')
+ ->get()
+ ->filter(function (Discount $discount) use ($subtotal, $lines): bool {
+ if ($discount->starts_at !== null && $discount->starts_at->isFuture()) {
+ return false;
+ }
+
+ if ($discount->ends_at !== null && $discount->ends_at->isPast()) {
+ return false;
+ }
+
+ if ($discount->usage_limit !== null && $discount->usage_count >= $discount->usage_limit) {
+ return false;
+ }
+
+ $minPurchase = $discount->rules_json['min_purchase_amount'] ?? null;
+
+ if ($minPurchase !== null && $subtotal < (int) $minPurchase) {
+ return false;
+ }
+
+ return $this->qualifyingLineIndexes($discount, $lines) !== [];
+ })
+ ->values();
+ }
+
+ /**
+ * Indexes of the lines the discount applies to: union of
+ * applicable_product_ids / applicable_collection_ids rules; when both
+ * are empty the discount applies to every line.
+ *
+ * @param array}> $lines
+ * @return array
+ */
+ public function qualifyingLineIndexes(Discount $discount, array $lines): array
+ {
+ $productIds = array_map('intval', $discount->rules_json['applicable_product_ids'] ?? []);
+ $collectionIds = array_map('intval', $discount->rules_json['applicable_collection_ids'] ?? []);
+
+ if ($productIds === [] && $collectionIds === []) {
+ return array_keys($lines);
+ }
+
+ $qualifying = [];
+
+ foreach ($lines as $index => $line) {
+ $lineProductId = (int) ($line['product_id'] ?? 0);
+ $lineCollectionIds = array_map('intval', $line['collection_ids'] ?? []);
+
+ if (in_array($lineProductId, $productIds, true)
+ || array_intersect($collectionIds, $lineCollectionIds) !== []) {
+ $qualifying[] = $index;
+ }
+ }
+
+ return $qualifying;
+ }
+
+ /**
+ * Flat calculation representation of a cart's lines.
+ *
+ * @return array, line_subtotal_amount: int}>
+ */
+ private function linesForCalculation(Cart $cart): array
+ {
+ $cart->loadMissing('lines.variant.product.collections');
+
+ return $cart->lines->values()->map(fn ($line): array => [
+ 'variant_id' => $line->variant_id,
+ 'product_id' => $line->variant?->product_id,
+ 'collection_ids' => $line->variant?->product?->collections->pluck('id')->all() ?? [],
+ 'line_subtotal_amount' => $line->line_subtotal_amount,
+ ])->all();
+ }
+}
diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php
new file mode 100644
index 00000000..b101a2bd
--- /dev/null
+++ b/app/Services/PricingEngine.php
@@ -0,0 +1,135 @@
+ cart subtotal -> discounts -> discounted subtotal ->
+ * shipping -> tax -> total. The same inputs always produce the same output.
+ *
+ * Discount-before-tax semantics (spec 05 §8.5): for tax-inclusive stores the
+ * discount is subtracted from the gross subtotal and tax is extracted from
+ * the post-discount gross amounts.
+ */
+class PricingEngine
+{
+ public function __construct(
+ private DiscountService $discounts,
+ private TaxCalculator $taxCalculator,
+ ) {}
+
+ /**
+ * Run the full pricing pipeline.
+ *
+ * @param array, quantity: int, unit_price_amount: int, requires_shipping?: bool}> $lines
+ * @param array $automaticDiscounts stacked sequentially after the code discount
+ */
+ public function calculate(
+ array $lines,
+ ?Discount $codeDiscount,
+ array $automaticDiscounts,
+ ?ShippingRateVO $shippingRate,
+ TaxSettings $taxSettings,
+ ?Address $address,
+ string $currency = 'USD',
+ ): PricingResult {
+ // Steps 1-2: line subtotals and cart subtotal.
+ $lineSubtotals = [];
+ $subtotal = 0;
+
+ foreach ($lines as $index => $line) {
+ $lineSubtotal = $line['unit_price_amount'] * $line['quantity'];
+ $lineSubtotals[$index] = $lineSubtotal;
+ $subtotal += $lineSubtotal;
+ }
+
+ // Step 3: discounts. The code discount applies first; automatic
+ // discounts then stack sequentially on the remaining undiscounted
+ // amount of each line.
+ $lineDiscounts = array_fill_keys(array_keys($lines), 0);
+ $freeShippingApplied = false;
+
+ $appliedDiscounts = array_values(array_filter(
+ array_merge([$codeDiscount], $automaticDiscounts)
+ ));
+
+ foreach ($appliedDiscounts as $discount) {
+ if ($discount->value_type === DiscountValueType::FreeShipping) {
+ $freeShippingApplied = true;
+
+ continue;
+ }
+
+ $remainingLines = [];
+
+ foreach ($lines as $index => $line) {
+ $remainingLines[$index] = [
+ 'product_id' => $line['product_id'] ?? null,
+ 'collection_ids' => $line['collection_ids'] ?? [],
+ 'line_subtotal_amount' => $lineSubtotals[$index] - $lineDiscounts[$index],
+ ];
+ }
+
+ $result = $this->discounts->calculate($discount, $subtotal, $remainingLines);
+
+ foreach ($result['allocations'] as $index => $amount) {
+ $lineDiscounts[$index] += $amount;
+ }
+ }
+
+ $discountTotal = array_sum($lineDiscounts);
+
+ // Step 4: discounted subtotal.
+ $discountedSubtotal = $subtotal - $discountTotal;
+
+ // Step 5: shipping (zeroed by free-shipping discounts; digital-only
+ // carts never pay shipping).
+ $requiresShipping = collect($lines)->contains(
+ fn (array $line): bool => (bool) ($line['requires_shipping'] ?? false)
+ );
+
+ $shipping = $requiresShipping ? ($shippingRate?->amount ?? 0) : 0;
+
+ if ($freeShippingApplied) {
+ $shipping = 0;
+ }
+
+ // Step 6: tax on discounted line amounts plus shipping. Without an
+ // address no tax is calculated yet.
+ $taxResult = $address === null
+ ? TaxCalculationResult::zero()
+ : $this->taxCalculator->calculate(new TaxCalculationRequest(
+ lineItems: collect($lines)->map(fn (array $line, int $index): array => [
+ 'variant_id' => $line['variant_id'] ?? null,
+ 'amount' => $lineSubtotals[$index] - $lineDiscounts[$index],
+ ])->values()->all(),
+ shippingAmount: $shipping,
+ address: $address,
+ taxSettings: $taxSettings,
+ ));
+
+ // Step 7: total. Tax-inclusive prices already contain the tax.
+ $total = $discountedSubtotal + $shipping
+ + ($taxSettings->prices_include_tax ? 0 : $taxResult->totalAmount);
+
+ return new PricingResult(
+ subtotal: $subtotal,
+ discount: $discountTotal,
+ shipping: $shipping,
+ taxLines: $taxResult->taxLines,
+ taxTotal: $taxResult->totalAmount,
+ total: $total,
+ currency: $currency,
+ lineDiscounts: $lineDiscounts,
+ );
+ }
+}
diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php
new file mode 100644
index 00000000..e54202aa
--- /dev/null
+++ b/app/Services/ShippingCalculator.php
@@ -0,0 +1,155 @@
+where('store_id', $store->id)
+ ->get();
+
+ $bestMatch = null;
+ $bestSpecificity = -1;
+
+ foreach ($zones as $zone) {
+ $countryMatch = in_array($address->countryCode, $zone->countries_json ?? [], true);
+ $regionMatch = $address->provinceCode !== null
+ && in_array($address->provinceCode, $zone->regions_json ?? [], true);
+
+ if ($countryMatch && $regionMatch) {
+ $specificity = 2;
+ } elseif ($countryMatch) {
+ $specificity = 1;
+ } else {
+ continue;
+ }
+
+ if ($specificity > $bestSpecificity
+ || ($specificity === $bestSpecificity && $bestMatch !== null && $zone->id < $bestMatch->id)) {
+ $bestMatch = $zone;
+ $bestSpecificity = $specificity;
+ }
+ }
+
+ return $bestMatch;
+ }
+
+ /**
+ * All active, purchasable rates for the address as calculated options.
+ * Rates whose conditions (weight/price range) do not match are excluded.
+ * Carts with no shippable lines have no available rates at all.
+ *
+ * @return Collection
+ */
+ public function getAvailableRates(Store $store, Address $address, Cart $cart): Collection
+ {
+ $zone = $this->getMatchingZone($store, $address);
+
+ if ($zone === null || ! $cart->requiresShipping()) {
+ return collect();
+ }
+
+ return $zone->rates()
+ ->where('is_active', true)
+ ->get()
+ ->map(function (ShippingRate $rate) use ($cart): ?ShippingRateVO {
+ $amount = $this->calculate($rate, $cart);
+
+ if ($amount === null) {
+ return null;
+ }
+
+ $config = $rate->config_json ?? [];
+
+ return new ShippingRateVO(
+ id: $rate->id,
+ name: $rate->name,
+ amount: $amount,
+ type: $rate->type,
+ estimatedDaysMin: isset($config['estimated_days_min']) ? (int) $config['estimated_days_min'] : null,
+ estimatedDaysMax: isset($config['estimated_days_max']) ? (int) $config['estimated_days_max'] : null,
+ );
+ })
+ ->filter()
+ ->values();
+ }
+
+ /**
+ * Calculate the cost of a rate for a cart; null when the rate's
+ * conditions do not match (out of range / unsupported carrier stub).
+ */
+ public function calculate(ShippingRate $rate, Cart $cart): ?int
+ {
+ $config = $rate->config_json ?? [];
+
+ return match ($rate->type) {
+ ShippingRateType::Flat => isset($config['amount']) ? (int) $config['amount'] : null,
+ ShippingRateType::Weight => $this->calculateWeightRate($config, $cart),
+ ShippingRateType::Price => $this->calculatePriceRate($config, $cart->subtotal()),
+ ShippingRateType::Carrier => null, // carrier API integration stub
+ };
+ }
+
+ /**
+ * Match the cart's total shippable weight against the configured ranges.
+ *
+ * @param array $config
+ */
+ private function calculateWeightRate(array $config, Cart $cart): ?int
+ {
+ $totalWeight = 0;
+
+ foreach ($cart->lines as $line) {
+ if ($line->variant?->requires_shipping) {
+ $totalWeight += (int) ($line->variant->weight_g ?? 0) * $line->quantity;
+ }
+ }
+
+ foreach ($config['ranges'] ?? [] as $range) {
+ if ($range['min_g'] <= $totalWeight && $totalWeight <= $range['max_g']) {
+ return (int) $range['amount'];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Match the cart subtotal against the configured ranges. A range without
+ * max_amount is open-ended ("free shipping over X").
+ *
+ * @param array $config
+ */
+ private function calculatePriceRate(array $config, int $cartSubtotal): ?int
+ {
+ foreach ($config['ranges'] ?? [] as $range) {
+ if ($range['min_amount'] <= $cartSubtotal
+ && (! isset($range['max_amount']) || $cartSubtotal <= $range['max_amount'])) {
+ return (int) $range['amount'];
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/app/Services/Tax/ManualTaxProvider.php b/app/Services/Tax/ManualTaxProvider.php
new file mode 100644
index 00000000..df5f9297
--- /dev/null
+++ b/app/Services/Tax/ManualTaxProvider.php
@@ -0,0 +1,113 @@
+taxSettings;
+ $rateBps = $this->resolveRate($request);
+
+ if ($rateBps <= 0) {
+ return TaxCalculationResult::zero();
+ }
+
+ $name = (string) ($settings->config_json['tax_name'] ?? 'Tax');
+ $jurisdiction = $request->address?->countryCode;
+ $inclusive = $settings->prices_include_tax;
+
+ $lineDetails = [];
+ $total = 0;
+
+ foreach ($request->lineItems as $item) {
+ $tax = $inclusive
+ ? $this->extractInclusive($item['amount'], $rateBps)
+ : $this->addExclusive($item['amount'], $rateBps);
+
+ $lineDetails[] = [
+ 'variant_id' => $item['variant_id'] ?? null,
+ 'tax_amount' => $tax,
+ 'rate' => $rateBps,
+ 'jurisdiction' => $jurisdiction,
+ ];
+ $total += $tax;
+ }
+
+ $shippingTax = $inclusive
+ ? $this->extractInclusive($request->shippingAmount, $rateBps)
+ : $this->addExclusive($request->shippingAmount, $rateBps);
+ $total += $shippingTax;
+
+ return new TaxCalculationResult(
+ taxLines: [new TaxLine(name: $name, rate: $rateBps, amount: $total)],
+ totalAmount: $total,
+ lineDetails: $lineDetails,
+ shippingTaxAmount: $shippingTax,
+ shippingTaxRate: $rateBps,
+ );
+ }
+
+ /**
+ * Tax added on top of a net amount. Integer truncation per line, summed
+ * afterwards (spec 09 test tables: 8999 @ 700 bps = 629).
+ */
+ public function addExclusive(int $netAmount, int $rateBasisPoints): int
+ {
+ return intdiv($netAmount * $rateBasisPoints, 10000);
+ }
+
+ /**
+ * Tax portion contained in a gross (tax-inclusive) amount.
+ * net = intdiv(gross * 10000, 10000 + rate); tax = gross - net.
+ */
+ public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
+ {
+ $net = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints);
+
+ return $grossAmount - $net;
+ }
+
+ /**
+ * Resolve the applicable rate in basis points for the request.
+ */
+ private function resolveRate(TaxCalculationRequest $request): int
+ {
+ $config = $request->taxSettings->config_json ?? [];
+ $zoneRates = $config['zone_rates'] ?? null;
+
+ if (is_array($zoneRates) && $zoneRates !== [] && $request->address !== null) {
+ $store = Store::find($request->taxSettings->store_id);
+
+ if ($store !== null) {
+ $zone = $this->shippingCalculator->getMatchingZone($store, $request->address);
+
+ if ($zone !== null && isset($zoneRates[$zone->id])) {
+ return (int) $zoneRates[$zone->id];
+ }
+ }
+ }
+
+ return (int) ($config['default_rate_bps'] ?? 0);
+ }
+}
diff --git a/app/Services/Tax/StripeTaxProvider.php b/app/Services/Tax/StripeTaxProvider.php
new file mode 100644
index 00000000..db8f7e0c
--- /dev/null
+++ b/app/Services/Tax/StripeTaxProvider.php
@@ -0,0 +1,29 @@
+resolveProvider($request)->calculate($request);
+ }
+
+ /**
+ * Tax added on top of a net amount (integer math, per-line truncation).
+ */
+ public function addExclusive(int $netAmount, int $rateBasisPoints): int
+ {
+ return $this->manualProvider->addExclusive($netAmount, $rateBasisPoints);
+ }
+
+ /**
+ * Tax portion contained in a gross (tax-inclusive) amount.
+ */
+ public function extractInclusive(int $grossAmount, int $rateBasisPoints): int
+ {
+ return $this->manualProvider->extractInclusive($grossAmount, $rateBasisPoints);
+ }
+
+ /**
+ * Pick the provider implementation for the request's tax settings.
+ */
+ private function resolveProvider(TaxCalculationRequest $request): TaxProvider
+ {
+ $settings = $request->taxSettings;
+
+ if ($settings->mode === TaxMode::Provider && $settings->provider === 'stripe') {
+ return $this->stripeProvider;
+ }
+
+ return $this->manualProvider;
+ }
+}
diff --git a/app/ValueObjects/Address.php b/app/ValueObjects/Address.php
new file mode 100644
index 00000000..ccae3dbf
--- /dev/null
+++ b/app/ValueObjects/Address.php
@@ -0,0 +1,72 @@
+ $data
+ */
+ public static function fromArray(array $data): self
+ {
+ return new self(
+ firstName: (string) ($data['first_name'] ?? ''),
+ lastName: (string) ($data['last_name'] ?? ''),
+ company: $data['company'] ?? null,
+ address1: (string) ($data['address1'] ?? ''),
+ address2: $data['address2'] ?? null,
+ city: (string) ($data['city'] ?? ''),
+ province: $data['province'] ?? null,
+ provinceCode: $data['province_code'] ?? null,
+ country: (string) ($data['country'] ?? $data['country_code'] ?? ''),
+ countryCode: (string) ($data['country_code'] ?? $data['country'] ?? ''),
+ postalCode: (string) ($data['postal_code'] ?? ''),
+ phone: $data['phone'] ?? null,
+ );
+ }
+
+ /**
+ * Serialize to the snake_case JSON representation stored on checkouts.
+ *
+ * @return array
+ */
+ public function toArray(): array
+ {
+ return [
+ 'first_name' => $this->firstName,
+ 'last_name' => $this->lastName,
+ 'company' => $this->company,
+ 'address1' => $this->address1,
+ 'address2' => $this->address2,
+ 'city' => $this->city,
+ 'province' => $this->province,
+ 'province_code' => $this->provinceCode,
+ 'country' => $this->country,
+ 'country_code' => $this->countryCode,
+ 'postal_code' => $this->postalCode,
+ 'phone' => $this->phone,
+ ];
+ }
+}
diff --git a/app/ValueObjects/DiscountValidationResult.php b/app/ValueObjects/DiscountValidationResult.php
new file mode 100644
index 00000000..6a83f4bb
--- /dev/null
+++ b/app/ValueObjects/DiscountValidationResult.php
@@ -0,0 +1,28 @@
+ $taxLines
+ * @param array $lineDiscounts discount amount per line index
+ */
+ public function __construct(
+ public int $subtotal,
+ public int $discount,
+ public int $shipping,
+ public array $taxLines,
+ public int $taxTotal,
+ public int $total,
+ public string $currency,
+ public array $lineDiscounts = [],
+ ) {}
+
+ /**
+ * Snapshot structure stored in checkouts.totals_json.
+ *
+ * @return array{subtotal: int, discount: int, shipping: int, tax: int, tax_lines: array, total: int, currency: string}
+ */
+ public function toArray(): array
+ {
+ return [
+ 'subtotal' => $this->subtotal,
+ 'discount' => $this->discount,
+ 'shipping' => $this->shipping,
+ 'tax' => $this->taxTotal,
+ 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines),
+ 'total' => $this->total,
+ 'currency' => $this->currency,
+ ];
+ }
+}
diff --git a/app/ValueObjects/ShippingRateVO.php b/app/ValueObjects/ShippingRateVO.php
new file mode 100644
index 00000000..8683a3f5
--- /dev/null
+++ b/app/ValueObjects/ShippingRateVO.php
@@ -0,0 +1,23 @@
+ $lineItems discounted line amounts
+ */
+ public function __construct(
+ public array $lineItems,
+ public int $shippingAmount,
+ public ?Address $address,
+ public TaxSettings $taxSettings,
+ ) {}
+}
diff --git a/app/ValueObjects/TaxCalculationResult.php b/app/ValueObjects/TaxCalculationResult.php
new file mode 100644
index 00000000..90ed91b1
--- /dev/null
+++ b/app/ValueObjects/TaxCalculationResult.php
@@ -0,0 +1,30 @@
+ $taxLines
+ * @param array $lineDetails
+ */
+ public function __construct(
+ public array $taxLines,
+ public int $totalAmount,
+ public array $lineDetails = [],
+ public int $shippingTaxAmount = 0,
+ public int $shippingTaxRate = 0,
+ ) {}
+
+ /**
+ * Zero-tax result.
+ */
+ public static function zero(): self
+ {
+ return new self(taxLines: [], totalAmount: 0);
+ }
+}
diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php
new file mode 100644
index 00000000..b7da73a7
--- /dev/null
+++ b/app/ValueObjects/TaxLine.php
@@ -0,0 +1,28 @@
+ $this->name,
+ 'rate' => $this->rate,
+ 'amount' => $this->amount,
+ ];
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 286a5e97..771d76e8 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -26,5 +26,28 @@
]);
})
->withExceptions(function (Exceptions $exceptions): void {
- //
+ // Cart optimistic-concurrency conflicts return 409 with the current
+ // cart state in the response body (spec 02 §2.1, spec 05 §4.3).
+ $exceptions->render(function (App\Exceptions\CartVersionMismatchException $exception, Illuminate\Http\Request $request) {
+ if (! $request->expectsJson()) {
+ return null;
+ }
+
+ return response()->json(
+ array_merge(
+ ['message' => $exception->getMessage()],
+ (new App\Http\Resources\Storefront\CartResource($exception->cart->refresh()))->toArray($request),
+ ),
+ 409,
+ );
+ });
+
+ // Invalid checkout state transitions map to 422 (spec 02 §2.2).
+ $exceptions->render(function (App\Exceptions\InvalidCheckoutTransitionException $exception, Illuminate\Http\Request $request) {
+ if (! $request->expectsJson()) {
+ return null;
+ }
+
+ return response()->json(['message' => $exception->getMessage()], 422);
+ });
})->create();
diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php
new file mode 100644
index 00000000..7e35d835
--- /dev/null
+++ b/database/factories/CartFactory.php
@@ -0,0 +1,49 @@
+
+ */
+class CartFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'customer_id' => null,
+ 'currency' => 'USD',
+ 'cart_version' => 1,
+ 'status' => CartStatus::Active,
+ ];
+ }
+
+ /**
+ * Indicate that the cart is abandoned.
+ */
+ public function abandoned(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => CartStatus::Abandoned,
+ ]);
+ }
+
+ /**
+ * Indicate that the cart is converted.
+ */
+ public function converted(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => CartStatus::Converted,
+ ]);
+ }
+}
diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php
new file mode 100644
index 00000000..32e73a40
--- /dev/null
+++ b/database/factories/CartLineFactory.php
@@ -0,0 +1,43 @@
+
+ */
+class CartLineFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'cart_id' => Cart::factory(),
+ 'variant_id' => ProductVariant::factory(),
+ 'quantity' => 1,
+ 'unit_price_amount' => fake()->numberBetween(100, 10000),
+ 'line_subtotal_amount' => 0,
+ 'line_discount_amount' => 0,
+ 'line_total_amount' => 0,
+ ];
+ }
+
+ /**
+ * Recalculate derived amounts after creation.
+ */
+ public function configure(): static
+ {
+ return $this->afterMaking(function (CartLine $line): void {
+ $line->line_subtotal_amount = $line->unit_price_amount * $line->quantity;
+ $line->line_total_amount = $line->line_subtotal_amount - $line->line_discount_amount;
+ });
+ }
+}
diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php
new file mode 100644
index 00000000..cd58d9a8
--- /dev/null
+++ b/database/factories/CheckoutFactory.php
@@ -0,0 +1,58 @@
+
+ */
+class CheckoutFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => fn (array $attributes) => Cart::find($attributes['cart_id'])?->store_id,
+ 'cart_id' => Cart::factory(),
+ 'customer_id' => null,
+ 'status' => CheckoutStatus::Started,
+ 'payment_method' => null,
+ 'email' => fake()->safeEmail(),
+ 'shipping_address_json' => null,
+ 'billing_address_json' => null,
+ 'shipping_method_id' => null,
+ 'discount_code' => null,
+ 'tax_provider_snapshot_json' => null,
+ 'totals_json' => null,
+ 'expires_at' => now()->addHours(24),
+ ];
+ }
+
+ /**
+ * Indicate that the checkout has an address set.
+ */
+ public function addressed(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => CheckoutStatus::Addressed,
+ ]);
+ }
+
+ /**
+ * Indicate that the checkout is expired.
+ */
+ public function expired(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => CheckoutStatus::Expired,
+ 'expires_at' => now()->subHour(),
+ ]);
+ }
+}
diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php
new file mode 100644
index 00000000..91663c3d
--- /dev/null
+++ b/database/factories/DiscountFactory.php
@@ -0,0 +1,113 @@
+
+ */
+class DiscountFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'type' => DiscountType::Code,
+ 'code' => strtoupper(fake()->unique()->bothify('CODE##??')),
+ 'value_type' => DiscountValueType::Percent,
+ 'value_amount' => 10,
+ 'starts_at' => now()->subDay(),
+ 'ends_at' => null,
+ 'usage_limit' => null,
+ 'usage_count' => 0,
+ 'rules_json' => [],
+ 'status' => DiscountStatus::Active,
+ ];
+ }
+
+ /**
+ * Indicate a percent discount with the given whole percentage.
+ */
+ public function percent(int $value = 10): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'value_type' => DiscountValueType::Percent,
+ 'value_amount' => $value,
+ ]);
+ }
+
+ /**
+ * Indicate a fixed-amount discount in minor units.
+ */
+ public function fixed(int $amount = 500): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'value_type' => DiscountValueType::Fixed,
+ 'value_amount' => $amount,
+ ]);
+ }
+
+ /**
+ * Indicate a free-shipping discount.
+ */
+ public function freeShipping(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'value_type' => DiscountValueType::FreeShipping,
+ 'value_amount' => 0,
+ ]);
+ }
+
+ /**
+ * Indicate an automatic (codeless) discount.
+ */
+ public function automatic(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => DiscountType::Automatic,
+ 'code' => null,
+ ]);
+ }
+
+ /**
+ * Indicate that the discount is expired.
+ */
+ public function expired(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'starts_at' => now()->subDays(10),
+ 'ends_at' => now()->subDay(),
+ ]);
+ }
+
+ /**
+ * Indicate that the discount has reached its usage limit.
+ */
+ public function maxedOut(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'usage_limit' => 10,
+ 'usage_count' => 10,
+ ]);
+ }
+
+ /**
+ * Set a minimum purchase amount rule in minor units.
+ */
+ public function withMinPurchase(int $amount): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'rules_json' => array_merge($attributes['rules_json'] ?? [], ['min_purchase_amount' => $amount]),
+ ]);
+ }
+}
diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php
new file mode 100644
index 00000000..5692a3f8
--- /dev/null
+++ b/database/factories/ShippingRateFactory.php
@@ -0,0 +1,86 @@
+
+ */
+class ShippingRateFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'zone_id' => ShippingZone::factory(),
+ 'name' => 'Standard Shipping',
+ 'type' => ShippingRateType::Flat,
+ 'config_json' => ['amount' => 500],
+ 'is_active' => true,
+ ];
+ }
+
+ /**
+ * Indicate a flat rate with the given amount.
+ */
+ public function flat(int $amount = 500): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => ShippingRateType::Flat,
+ 'config_json' => ['amount' => $amount],
+ ]);
+ }
+
+ /**
+ * Indicate a weight-based rate with the given ranges.
+ *
+ * @param array $ranges
+ */
+ public function weight(array $ranges = []): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => ShippingRateType::Weight,
+ 'config_json' => [
+ 'ranges' => $ranges !== [] ? $ranges : [
+ ['min_g' => 0, 'max_g' => 1000, 'amount' => 500],
+ ['min_g' => 1001, 'max_g' => 5000, 'amount' => 1000],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Indicate a price-based rate with the given ranges.
+ *
+ * @param array $ranges
+ */
+ public function price(array $ranges = []): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'type' => ShippingRateType::Price,
+ 'config_json' => [
+ 'ranges' => $ranges !== [] ? $ranges : [
+ ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 500],
+ ['min_amount' => 5001, 'amount' => 0],
+ ],
+ ],
+ ]);
+ }
+
+ /**
+ * Indicate that the rate is inactive.
+ */
+ public function inactive(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_active' => false,
+ ]);
+ }
+}
diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php
new file mode 100644
index 00000000..e9cebe72
--- /dev/null
+++ b/database/factories/ShippingZoneFactory.php
@@ -0,0 +1,27 @@
+
+ */
+class ShippingZoneFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'name' => fake()->words(2, true),
+ 'countries_json' => ['DE'],
+ 'regions_json' => [],
+ ];
+ }
+}
diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php
new file mode 100644
index 00000000..23358c15
--- /dev/null
+++ b/database/factories/TaxSettingsFactory.php
@@ -0,0 +1,49 @@
+
+ */
+class TaxSettingsFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'mode' => TaxMode::Manual,
+ 'provider' => 'manual',
+ 'prices_include_tax' => false,
+ 'config_json' => ['default_rate_bps' => 1900],
+ ];
+ }
+
+ /**
+ * Indicate that displayed prices include tax.
+ */
+ public function inclusive(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'prices_include_tax' => true,
+ ]);
+ }
+
+ /**
+ * Set the default manual rate in basis points.
+ */
+ public function withRate(int $rateBps): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'config_json' => array_merge($attributes['config_json'] ?? [], ['default_rate_bps' => $rateBps]),
+ ]);
+ }
+}
diff --git a/resources/views/livewire/storefront/cart-drawer.blade.php b/resources/views/livewire/storefront/cart-drawer.blade.php
new file mode 100644
index 00000000..63fb3142
--- /dev/null
+++ b/resources/views/livewire/storefront/cart-drawer.blade.php
@@ -0,0 +1,137 @@
+
+ {{-- Header --}}
+
+
+ Your Cart @if ($cart !== null && $cart->itemCount() > 0) ({{ $cart->itemCount() }}) @endif
+
+
+
+
+
+
+
+
+ @if ($cart === null || $cart->lines->isEmpty())
+ {{-- Empty state (spec 04 §6.7) --}}
+
+
+
+
+
Your cart is empty
+
+ Continue shopping
+
+
+ @else
+ {{-- Line items --}}
+
+ @foreach ($cart->lines as $line)
+
+ @php $image = $line->variant?->product?->media->first(); @endphp
+ @if ($image !== null)
+
+ @else
+
+ @endif
+
+
{{ $line->variant?->product?->title }}
+
{{ $line->variant?->title() }}
+
+
+
+
+
+
{{ $line->quantity }}
+
+
+
+
+
{{ \App\Support\Money::format($line->line_total_amount, $cart->currency) }}
+
+
+
+
+ @endforeach
+
+
+ {{-- Discount code (spec 04 §6.4) --}}
+
+ @if ($discount !== null)
+
+
+ {{ $discount['code'] }} ({{ $discount['label'] }})
+
+
+ Remove
+
+
+ @else
+
+ @if ($discountError !== null)
+
{{ $discountError }}
+ @endif
+ @endif
+
+
+ {{-- Totals (spec 04 §6.5) --}}
+
+
+
+
Subtotal
+ {{ \App\Support\Money::format($cart->subtotal(), $cart->currency) }}
+
+ @if ($discount !== null && $discount['amount'] > 0)
+
+
Discount ({{ $discount['code'] }})
+ -{{ \App\Support\Money::format($discount['amount'], $cart->currency) }}
+
+ @endif
+
+
Estimated total
+ {{ \App\Support\Money::format($cart->subtotal() - ($discount['amount'] ?? 0), $cart->currency) }}
+
+
+
Shipping and taxes calculated at checkout
+
+ Checkout
+
+
+
+ Continue shopping
+
+
+
+ @endif
+
diff --git a/resources/views/livewire/storefront/cart/show.blade.php b/resources/views/livewire/storefront/cart/show.blade.php
new file mode 100644
index 00000000..bb7bf53b
--- /dev/null
+++ b/resources/views/livewire/storefront/cart/show.blade.php
@@ -0,0 +1,174 @@
+
+
Your Cart
+
+ @if ($cart === null || $cart->lines->isEmpty())
+ {{-- Empty state (spec 04 §7.3) --}}
+
+
+
+
+
Your cart is empty
+
+ Continue shopping
+
+
+ @else
+
+ {{-- Line items: table on desktop, cards on mobile (spec 04 §7.1) --}}
+
+
+
+
+ Product
+ Price
+ Quantity
+ Total
+ Remove
+
+
+
+ @foreach ($cart->lines as $line)
+
+
+
+ @php $image = $line->variant?->product?->media->first(); @endphp
+ @if ($image !== null)
+
+ @else
+
+ @endif
+
+
{{ $line->variant?->product?->title }}
+
{{ $line->variant?->title() }}
+
+
+
+ {{ \App\Support\Money::format($line->unit_price_amount, $cart->currency) }}
+
+
+
+
+
+
{{ $line->quantity }}
+
+
+
+
+
+ {{ \App\Support\Money::format($line->line_total_amount, $cart->currency) }}
+
+
+
+
+
+
+
+
+ @endforeach
+
+
+
+ {{-- Mobile cards --}}
+
+ @foreach ($cart->lines as $line)
+
+ @php $image = $line->variant?->product?->media->first(); @endphp
+ @if ($image !== null)
+
+ @else
+
+ @endif
+
+
{{ $line->variant?->product?->title }}
+
{{ $line->variant?->title() }}
+
+
+
+
+
+
{{ $line->quantity }}
+
+
+
+
+
{{ \App\Support\Money::format($line->line_total_amount, $cart->currency) }}
+
+
+
+ @endforeach
+
+
+
+ {{-- Order summary (spec 04 §7.2) --}}
+
+
+
Order summary
+
+
+ @if ($discount !== null)
+
+
{{ $discount['code'] }} ({{ $discount['label'] }})
+
Remove
+
+ @else
+
+ @if ($discountError !== null)
+
{{ $discountError }}
+ @endif
+ @endif
+
+
+
+
+
Subtotal
+ {{ \App\Support\Money::format($cart->subtotal(), $cart->currency) }}
+
+ @if ($discount !== null && $discount['amount'] > 0)
+
+
Discount
+ -{{ \App\Support\Money::format($discount['amount'], $cart->currency) }}
+
+ @endif
+
+
Total
+ {{ \App\Support\Money::format($cart->subtotal() - ($discount['amount'] ?? 0), $cart->currency) }}
+
+
+
Shipping estimated at checkout. Taxes calculated at checkout.
+
+
+ Checkout
+
+
+
+
+
+ @endif
+
diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php
new file mode 100644
index 00000000..1ee400ee
--- /dev/null
+++ b/resources/views/livewire/storefront/checkout/show.blade.php
@@ -0,0 +1,269 @@
+
+ @if ($expired)
+ {{-- Expired checkout (410-style) --}}
+
+
+
+
+
This checkout has expired
+
Checkouts are held for 24 hours. Your cart is still saved — start a new checkout when you're ready.
+
+ Return to cart
+
+
+ @else
+ @php
+ $totals = $checkout?->totals_json ?? [];
+ $summaryLines = $checkout?->cart?->lines ?? $previewCart?->lines ?? collect();
+ $summaryCurrency = $checkout?->cart?->currency ?? $previewCart?->currency ?? app('current_store')->default_currency;
+ $summarySubtotal = $checkout !== null ? ($totals['subtotal'] ?? 0) : ($previewCart?->subtotal() ?? 0);
+ $summaryDiscount = $totals['discount'] ?? 0;
+ $summaryShipping = $totals['shipping'] ?? null;
+ $summaryTax = $totals['tax'] ?? 0;
+ $summaryTotal = $checkout !== null ? ($totals['total'] ?? 0) : $summarySubtotal;
+ @endphp
+
+
Checkout
+
+
+ {{-- Steps --}}
+
+ {{-- Step 1: Contact & shipping address --}}
+
+
+ 1. Contact & shipping address
+ @if ($step > 1)
+ {{ $email }}
+ @endif
+
+
+ @if ($step === 1)
+
+ @elseif ($checkout !== null && ! empty($checkout->shipping_address_json))
+
+ {{ $checkout->shipping_address_json['first_name'] ?? '' }} {{ $checkout->shipping_address_json['last_name'] ?? '' }},
+ {{ $checkout->shipping_address_json['address1'] ?? '' }},
+ {{ $checkout->shipping_address_json['postal_code'] ?? '' }} {{ $checkout->shipping_address_json['city'] ?? '' }},
+ {{ $checkout->shipping_address_json['country_code'] ?? '' }}
+
+ @endif
+
+
+ {{-- Step 2: Shipping method --}}
+
+
+ 2. Shipping method
+
+
+ @if ($step === 2 && $checkout !== null)
+ @if (! $checkout->requiresShipping())
+
+
This order does not require shipping.
+
+ Continue to payment
+
+
+ @elseif ($rates->isEmpty())
+
+
+
+
+
+ No shipping methods are available for your address. Please verify your address or contact us.
+
+
+ @else
+
+ Available shipping methods
+ @foreach ($rates as $rate)
+
+ {{ $rate->name }}
+
+ @if ($rate->estimatedDaysMin !== null && $rate->estimatedDaysMax !== null)
+ {{ $rate->estimatedDaysMin }}-{{ $rate->estimatedDaysMax }} business days
+ @endif
+
+
+ {{ $rate->amount > 0 ? \App\Support\Money::format($rate->amount, $summaryCurrency) : 'Free' }}
+
+
+ @endforeach
+ @error('shippingMethodId') {{ $message }}
@enderror
+
+ @endif
+ @elseif ($step > 2 && $checkout !== null)
+
+ {{ $checkout->requiresShipping() ? 'Shipping method selected' : 'No shipping required' }}
+
+ @endif
+
+
+ {{-- Step 3: Payment method --}}
+
+
+ 3. Payment
+
+
+ @if ($step === 3)
+
+
+ Select a payment method
+ @foreach (['credit_card' => 'Credit Card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank Transfer'] as $value => $label)
+
+
+ {{ $label }}
+
+ @endforeach
+
+
+ @if (! $paymentSelected)
+
+ Continue
+ Reserving...
+
+ @else
+
+ Payment method saved and inventory reserved.
+
+
+ Pay now - {{ \App\Support\Money::format($summaryTotal, $summaryCurrency) }}
+
+
Payment processing arrives with Phase 5. Your order cannot be placed yet.
+ @endif
+
+ @endif
+
+
+
+ {{-- Order summary (spec 04 §8.3) --}}
+
+
+ @endif
+
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php
index 3e2c9785..62f847db 100644
--- a/resources/views/storefront/layouts/app.blade.php
+++ b/resources/views/storefront/layouts/app.blade.php
@@ -140,15 +140,17 @@ class="hidden rounded-md p-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900
- {{-- Item count badge (wired to the cart in Phase 4) --}}
- 0
+
@@ -254,7 +256,7 @@ class="block rounded-md px-3 py-3 text-base font-medium text-gray-700 hover:bg-g
- {{-- Cart drawer shell (inner content replaced by the real cart component in Phase 4) --}}
+ {{-- Cart drawer (shell here, content in the CartDrawer Livewire component) --}}
@@ -268,28 +270,7 @@ class="fixed inset-0 z-40 bg-gray-900/50 dark:bg-black/60" aria-hidden="true">
x-transition:enter="transition ease-out duration-200" x-transition:enter-start="translate-x-full" x-transition:enter-end="translate-x-0"
x-transition:leave="transition ease-in duration-150" x-transition:leave-start="translate-x-0" x-transition:leave-end="translate-x-full"
class="fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-white shadow-xl sm:w-96 dark:bg-gray-950">
-
-
-
-
-
-
Cart is empty
-
- Continue shopping
-
-
+
diff --git a/routes/api.php b/routes/api.php
index 347471b7..514a6277 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,12 +1,30 @@
prefix('storefront/v1')
->group(function (): void {
- //
+ // Cart endpoints (spec 02 §2.1).
+ Route::post('/carts', [CartController::class, 'create']);
+ Route::get('/carts/{cartId}', [CartController::class, 'show']);
+ Route::post('/carts/{cartId}/lines', [CartController::class, 'addLine']);
+ Route::put('/carts/{cartId}/lines/{lineId}', [CartController::class, 'updateLine']);
+ Route::delete('/carts/{cartId}/lines/{lineId}', [CartController::class, 'removeLine']);
+
+ // Checkout endpoints (spec 02 §2.2) with the stricter checkout rate limit on top.
+ Route::middleware('throttle:checkout')->group(function (): void {
+ Route::post('/checkouts', [CheckoutController::class, 'create']);
+ Route::get('/checkouts/{checkoutId}', [CheckoutController::class, 'show']);
+ Route::put('/checkouts/{checkoutId}/address', [CheckoutController::class, 'setAddress']);
+ Route::put('/checkouts/{checkoutId}/shipping-method', [CheckoutController::class, 'setShippingMethod']);
+ Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'selectPaymentMethod']);
+ Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount']);
+ Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']);
+ });
});
// Admin REST API (Sanctum personal access tokens). Endpoints are added in later phases.
diff --git a/routes/console.php b/routes/console.php
index 3c9adf1a..a8ef7a2f 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -1,8 +1,15 @@
comment(Inspiring::quote());
})->purpose('Display an inspiring quote');
+
+// Expire stale checkouts (spec 05 §6.2) and abandon inactive carts (spec 05 §4.5).
+Schedule::job(new ExpireAbandonedCheckouts)->everyFifteenMinutes();
+Schedule::job(new CleanupAbandonedCarts)->daily();
diff --git a/routes/web.php b/routes/web.php
index fa0b31bd..7ea408ea 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,5 +1,7 @@
name('storefront.collections.index');
Route::livewire('/collections/{handle}', CollectionsShow::class)->name('storefront.collections.show');
Route::livewire('/products/{handle}', ProductsShow::class)->name('storefront.products.show');
+ Route::livewire('/cart', CartShow::class)->name('storefront.cart.show');
+ Route::livewire('/checkout/{checkoutId}', CheckoutShow::class)->name('storefront.checkout.show');
Route::livewire('/pages/{handle}', PagesShow::class)->name('storefront.pages.show');
});
diff --git a/specs/progress.md b/specs/progress.md
index a922537f..875b4ff7 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -23,6 +23,8 @@
| 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 |
+| 4 | 2026-07-19 | Phase 4 | Cart/Checkout/Discount/Shipping/Tax engine: 7 models, 7 VOs, PricingEngine, DiscountService, ShippingCalculator, TaxCalculator(+providers), CartService, CheckoutService state machine, storefront cart/checkout REST API, cart drawer + cart page + checkout stepper UI, expiry/cleanup jobs. 219 tests green. Notable: intdiv tax math & merge-sum per spec 09 precedence. | pending |
+
## Phase Checklist
### Phase 1 — Foundation
@@ -51,12 +53,12 @@
- [x] Storefront Livewire: Home, Collections Index/Show, Products Show, Pages Show
### Phase 4 — Cart / Checkout / Discounts / Shipping / Taxes
-- [ ] Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
-- [ ] CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService
-- [ ] Value objects: PricingResult, TaxLine, Address, etc.
-- [ ] Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts
-- [ ] Storefront cart/checkout Livewire UI + REST API endpoints
-- [ ] Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest
+- [x] Models: Cart, CartLine, Checkout, ShippingZone, ShippingRate, TaxSettings, Discount
+- [x] CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService
+- [x] Value objects: PricingResult, TaxLine, Address, etc.
+- [x] Jobs: ExpireAbandonedCheckouts, CleanupAbandonedCarts
+- [x] Storefront cart/checkout Livewire UI + REST API endpoints
+- [x] Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest
### Phase 5 — Payments / Orders / Fulfillment
- [ ] Models: Customer, CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine
diff --git a/tests/Feature/Cart/CartApiTest.php b/tests/Feature/Cart/CartApiTest.php
new file mode 100644
index 00000000..cc6205b0
--- /dev/null
+++ b/tests/Feature/Cart/CartApiTest.php
@@ -0,0 +1,172 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Create a store with a purchasable variant.
+ *
+ * @return array{0: Store, 1: ProductVariant}
+ */
+function cartApiSetup(): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ 'sku' => 'TSH-BLU-M',
+ ]);
+
+ return [$store, $variant];
+}
+
+test('creates a cart via API', function () {
+ [$store] = cartApiSetup();
+
+ $response = $this->postJson(cartApiUrl($store, '/carts'), ['currency' => 'EUR']);
+
+ $response->assertCreated()
+ ->assertJsonPath('currency', 'EUR')
+ ->assertJsonPath('cart_version', 1)
+ ->assertJsonPath('status', 'active')
+ ->assertJsonPath('lines', [])
+ ->assertJsonPath('totals.subtotal', 0)
+ ->assertJsonPath('totals.total', 0)
+ ->assertJsonStructure(['id', 'store_id', 'customer_id', 'created_at', 'updated_at']);
+});
+
+test('retrieves a cart via API', function () {
+ [$store, $variant] = cartApiSetup();
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $response = $this->getJson(cartApiUrl($store, "/carts/{$cart->id}"));
+
+ $response->assertOk()
+ ->assertJsonPath('id', $cart->id)
+ ->assertJsonPath('cart_version', 2)
+ ->assertJsonPath('totals.subtotal', 5000)
+ ->assertJsonPath('totals.total', 5000)
+ ->assertJsonPath('totals.line_count', 1)
+ ->assertJsonPath('totals.item_count', 2)
+ ->assertJsonPath('lines.0.variant_id', $variant->id)
+ ->assertJsonPath('lines.0.quantity', 2)
+ ->assertJsonPath('lines.0.unit_price_amount', 2500)
+ ->assertJsonPath('lines.0.sku', 'TSH-BLU-M')
+ ->assertJsonStructure(['lines' => [['product_title', 'variant_title', 'available_quantity', 'requires_shipping', 'image_url']]]);
+});
+
+test('adds a line via API', function () {
+ [$store, $variant] = cartApiSetup();
+ $cart = app(CartService::class)->create($store);
+
+ // spec 02 §2.1 defines 201 Created for this endpoint.
+ $response = $this->postJson(cartApiUrl($store, "/carts/{$cart->id}/lines"), [
+ 'variant_id' => $variant->id,
+ 'quantity' => 2,
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonPath('lines.0.variant_id', $variant->id)
+ ->assertJsonPath('lines.0.quantity', 2)
+ ->assertJsonPath('totals.subtotal', 5000)
+ ->assertJsonPath('cart_version', 2);
+});
+
+test('updates line quantity via API', function () {
+ [$store, $variant] = cartApiSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 1);
+
+ $response = $this->putJson(cartApiUrl($store, "/carts/{$cart->id}/lines/{$line->id}"), [
+ 'quantity' => 3,
+ 'cart_version' => 2,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('lines.0.quantity', 3)
+ ->assertJsonPath('totals.subtotal', 7500)
+ ->assertJsonPath('cart_version', 3);
+});
+
+test('removes a line via API', function () {
+ [$store, $variant] = cartApiSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 1);
+
+ $response = $this->deleteJson(cartApiUrl($store, "/carts/{$cart->id}/lines/{$line->id}"), [
+ 'cart_version' => 2,
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('lines', [])
+ ->assertJsonPath('totals.subtotal', 0)
+ ->assertJsonPath('cart_version', 3);
+});
+
+test('returns 404 for nonexistent cart', function () {
+ [$store] = cartApiSetup();
+
+ $this->getJson(cartApiUrl($store, '/carts/999999'))->assertNotFound();
+});
+
+test('returns 409 on version mismatch', function () {
+ [$store, $variant] = cartApiSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 1);
+ $service->addLine($cart->refresh(), $variant->id, 1); // version 3
+
+ $response = $this->putJson(cartApiUrl($store, "/carts/{$cart->id}/lines/{$line->id}"), [
+ 'quantity' => 5,
+ 'cart_version' => 2,
+ ]);
+
+ $response->assertConflict()
+ ->assertJsonPath('cart_version', 3)
+ ->assertJsonPath('id', $cart->id)
+ ->assertJsonStructure(['message', 'lines', 'totals']);
+
+ // The stale write must not have been applied.
+ expect($line->refresh()->quantity)->toBe(2);
+});
+
+test('returns 422 with field errors when the variant is out of stock', function () {
+ [$store, $variant] = cartApiSetup();
+ $variant->inventoryItem->update(['quantity_on_hand' => 1]);
+ $cart = app(CartService::class)->create($store);
+
+ $response = $this->postJson(cartApiUrl($store, "/carts/{$cart->id}/lines"), [
+ 'variant_id' => $variant->id,
+ 'quantity' => 5,
+ ]);
+
+ $response->assertUnprocessable()
+ ->assertJsonValidationErrors(['variant_id']);
+});
+
+test('respects storefront rate limiting', function () {
+ [$store] = cartApiSetup();
+ $cart = app(CartService::class)->create($store);
+
+ for ($i = 0; $i < 120; $i++) {
+ $this->getJson(cartApiUrl($store, "/carts/{$cart->id}"))->assertOk();
+ }
+
+ $this->getJson(cartApiUrl($store, "/carts/{$cart->id}"))->assertTooManyRequests();
+});
diff --git a/tests/Feature/Cart/CartServiceTest.php b/tests/Feature/Cart/CartServiceTest.php
new file mode 100644
index 00000000..f9857158
--- /dev/null
+++ b/tests/Feature/Cart/CartServiceTest.php
@@ -0,0 +1,207 @@
+ $variantAttributes
+ * @return array{0: Store, 1: ProductVariant}
+ */
+function cartServiceSetup(array $variantAttributes = [], int $onHand = 10): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory($onHand)->create(array_merge([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ], $variantAttributes));
+
+ return [$store, $variant];
+}
+
+test('creates a cart for the current store', function () {
+ [$store] = cartServiceSetup();
+
+ $cart = app(CartService::class)->create($store);
+
+ expect($cart->store_id)->toBe($store->id)
+ ->and($cart->currency)->toBe($store->default_currency)
+ ->and($cart->cart_version)->toBe(1)
+ ->and($cart->status)->toBe(CartStatus::Active);
+});
+
+test('adds a line item to the cart', function () {
+ [$store, $variant] = cartServiceSetup();
+ $cart = app(CartService::class)->create($store);
+
+ $line = app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ expect($line->unit_price_amount)->toBe(2500)
+ ->and($line->quantity)->toBe(2)
+ ->and($line->line_subtotal_amount)->toBe(5000)
+ ->and($line->line_total_amount)->toBe(5000);
+});
+
+test('increments quantity when adding an existing variant', function () {
+ [$store, $variant] = cartServiceSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+
+ $service->addLine($cart, $variant->id, 1);
+ $service->addLine($cart->refresh(), $variant->id, 2);
+
+ $cart->refresh();
+
+ expect($cart->lines)->toHaveCount(1)
+ ->and($cart->lines->first()->quantity)->toBe(3)
+ ->and($cart->lines->first()->line_subtotal_amount)->toBe(7500);
+});
+
+test('rejects add when product is not active', function () {
+ [$store, $variant] = cartServiceSetup();
+ $variant->product->update(['status' => \App\Enums\ProductStatus::Draft]);
+
+ $cart = app(CartService::class)->create($store);
+
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+})->throws(ValidationException::class);
+
+test('rejects add when inventory is insufficient and policy is deny', function () {
+ [$store, $variant] = cartServiceSetup([], onHand: 2);
+ $cart = app(CartService::class)->create($store);
+
+ app(CartService::class)->addLine($cart, $variant->id, 5);
+})->throws(InsufficientInventoryException::class);
+
+test('allows add when inventory is insufficient but policy is continue', function () {
+ [$store, $variant] = cartServiceSetup([], onHand: 2);
+ $variant->inventoryItem->update(['policy' => InventoryPolicy::Continue]);
+ $cart = app(CartService::class)->create($store);
+
+ $line = app(CartService::class)->addLine($cart, $variant->id, 5);
+
+ expect($line->quantity)->toBe(5);
+});
+
+test('updates line quantity', function () {
+ [$store, $variant] = cartServiceSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 2);
+
+ $service->updateLineQuantity($cart->refresh(), $line->id, 5);
+
+ $line->refresh();
+
+ expect($line->quantity)->toBe(5)
+ ->and($line->line_subtotal_amount)->toBe(12500)
+ ->and($line->line_total_amount)->toBe(12500);
+});
+
+test('removes a line when quantity set to zero', function () {
+ [$store, $variant] = cartServiceSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 2);
+
+ $service->updateLineQuantity($cart->refresh(), $line->id, 0);
+
+ expect($cart->refresh()->lines)->toHaveCount(0);
+});
+
+test('removes a specific line item', function () {
+ [$store, $variant] = cartServiceSetup();
+ $second = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $variant->product_id,
+ 'price_amount' => 1000,
+ ]);
+
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $first = $service->addLine($cart, $variant->id, 1);
+ $service->addLine($cart->refresh(), $second->id, 1);
+
+ $service->removeLine($cart->refresh(), $first->id);
+
+ $cart->refresh();
+
+ expect($cart->lines)->toHaveCount(1)
+ ->and($cart->lines->first()->variant_id)->toBe($second->id);
+});
+
+test('increments cart version on every mutation', function () {
+ [$store, $variant] = cartServiceSetup();
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+
+ $line = $service->addLine($cart, $variant->id, 1);
+ $service->updateLineQuantity($cart->refresh(), $line->id, 2);
+ $service->removeLine($cart->refresh(), $line->id);
+
+ expect($cart->refresh()->cart_version)->toBe(4);
+});
+
+test('returns cart via session for guest users', function () {
+ [$store] = cartServiceSetup();
+ $cart = app(CartService::class)->create($store);
+
+ session(['cart_id' => $cart->id]);
+
+ $found = app(CartService::class)->getOrCreateForSession($store);
+
+ expect($found->id)->toBe($cart->id);
+});
+
+test('merges guest cart into customer cart on login', function () {
+ [$store, $variantA] = cartServiceSetup();
+ $variantB = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $variantA->product_id,
+ 'price_amount' => 1000,
+ ]);
+
+ $service = app(CartService::class);
+
+ $guestCart = $service->create($store);
+ $service->addLine($guestCart, $variantA->id, 2);
+
+ $customerCart = $service->create($store);
+ $service->addLine($customerCart, $variantA->id, 1);
+ $service->addLine($customerCart->refresh(), $variantB->id, 3);
+
+ session(['cart_id' => $guestCart->id]);
+
+ $merged = $service->mergeOnLogin($guestCart->refresh(), $customerCart->refresh());
+
+ expect($merged->lines)->toHaveCount(2)
+ ->and($merged->findLineByVariant($variantA->id)->quantity)->toBe(3)
+ ->and($merged->findLineByVariant($variantB->id)->quantity)->toBe(3)
+ ->and($guestCart->refresh()->status)->toBe(CartStatus::Abandoned)
+ ->and(session('cart_id'))->toBeNull();
+});
+
+test('cleanup job abandons carts inactive for 14 days', function () {
+ [$store] = cartServiceSetup();
+ $service = app(CartService::class);
+
+ $stale = $service->create($store);
+ $recent = $service->create($store);
+
+ Cart::query()->whereKey($stale->id)->update(['updated_at' => now()->subDays(15)]);
+ Cart::query()->whereKey($recent->id)->update(['updated_at' => now()->subDays(2)]);
+
+ (new \App\Jobs\CleanupAbandonedCarts)->handle(app(\App\Services\CheckoutService::class));
+
+ expect($stale->refresh()->status)->toBe(CartStatus::Abandoned)
+ ->and($recent->refresh()->status)->toBe(CartStatus::Active);
+});
diff --git a/tests/Feature/Checkout/CheckoutFlowTest.php b/tests/Feature/Checkout/CheckoutFlowTest.php
new file mode 100644
index 00000000..7a3d200b
--- /dev/null
+++ b/tests/Feature/Checkout/CheckoutFlowTest.php
@@ -0,0 +1,168 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with variant (2500, shippable), DE zone with flat rate 499.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate}
+ */
+function checkoutFlowSetup(): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+function deAddress(): array
+{
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ];
+}
+
+test('creates a checkout from a cart', function () {
+ [$store, $variant] = checkoutFlowSetup();
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+ app(CartService::class)->addLine($cart->refresh(), $variant->id, 1);
+
+ $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'customer@example.com');
+
+ expect($checkout->status)->toBe(CheckoutStatus::Started)
+ ->and($checkout->cart_id)->toBe($cart->id)
+ ->and($checkout->email)->toBe('customer@example.com')
+ ->and($checkout->expires_at)->not->toBeNull()
+ ->and($checkout->totals_json['subtotal'])->toBe(7500);
+});
+
+test('walks the full checkout flow to payment selected', function () {
+ [$store, $variant, $rate] = checkoutFlowSetup();
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $response = $this->postJson(checkoutFlowApiUrl($store, '/checkouts'), [
+ 'cart_id' => $cart->id,
+ 'email' => 'customer@example.com',
+ ]);
+
+ $response->assertCreated()
+ ->assertJsonPath('status', 'started')
+ ->assertJsonPath('email', 'customer@example.com')
+ ->assertJsonPath('totals.subtotal', 5000)
+ ->assertJsonPath('available_shipping_methods', [])
+ ->assertJsonStructure(['id', 'cart_id', 'lines', 'expires_at', 'created_at']);
+
+ $checkoutId = $response->json('id');
+
+ $this->putJson(checkoutFlowApiUrl($store, "/checkouts/{$checkoutId}/address"), [
+ 'shipping_address' => deAddress(),
+ 'use_shipping_as_billing' => true,
+ ])
+ ->assertOk()
+ ->assertJsonPath('status', 'addressed')
+ ->assertJsonPath('shipping_address_json.city', 'Berlin')
+ ->assertJsonPath('billing_address_json.city', 'Berlin')
+ ->assertJsonPath('available_shipping_methods.0.id', $rate->id)
+ ->assertJsonPath('available_shipping_methods.0.price_amount', 499);
+
+ $this->putJson(checkoutFlowApiUrl($store, "/checkouts/{$checkoutId}/shipping-method"), [
+ 'shipping_method_id' => $rate->id,
+ ])
+ ->assertOk()
+ ->assertJsonPath('status', 'shipping_selected')
+ ->assertJsonPath('totals.shipping', 499);
+
+ $this->putJson(checkoutFlowApiUrl($store, "/checkouts/{$checkoutId}/payment-method"), [
+ 'payment_method' => 'credit_card',
+ ])
+ ->assertOk()
+ ->assertJsonPath('status', 'payment_selected')
+ ->assertJsonPath('payment_method', 'credit_card');
+
+ $checkout = Checkout::findOrFail($checkoutId);
+
+ expect($checkout->expires_at->isFuture())->toBeTrue()
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2);
+});
+
+test('rejects checkout for empty cart', function () {
+ [$store] = checkoutFlowSetup();
+ $cart = app(CartService::class)->create($store);
+
+ $this->postJson(checkoutFlowApiUrl($store, '/checkouts'), [
+ 'cart_id' => $cart->id,
+ 'email' => 'customer@example.com',
+ ])->assertUnprocessable()
+ ->assertJsonValidationErrors(['cart_id']);
+});
+
+test('expires checkout after timeout and releases reserved inventory', function () {
+ [$store, $variant, $rate] = checkoutFlowSetup();
+ $service = app(CheckoutService::class);
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 3);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, ['shipping_address' => deAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(3);
+
+ $checkout->update(['expires_at' => now()->subHour()]);
+
+ (new ExpireAbandonedCheckouts)->handle(app(CheckoutService::class));
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::Expired)
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0);
+});
+
+test('get checkout returns 410 when expired', function () {
+ [$store, $variant] = checkoutFlowSetup();
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+
+ $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout->update(['expires_at' => now()->subHour()]);
+
+ $this->getJson(checkoutFlowApiUrl($store, "/checkouts/{$checkout->id}"))
+ ->assertGone();
+});
diff --git a/tests/Feature/Checkout/CheckoutStateTest.php b/tests/Feature/Checkout/CheckoutStateTest.php
new file mode 100644
index 00000000..219a8626
--- /dev/null
+++ b/tests/Feature/Checkout/CheckoutStateTest.php
@@ -0,0 +1,201 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with variant (2500), DE zone + flat rate 499.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate}
+ */
+function checkoutStateSetup(array $variantAttributes = []): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create(array_merge([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ], $variantAttributes));
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+function stateAddress(string $country = 'DE'): array
+{
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => $country,
+ 'country_code' => $country,
+ 'postal_code' => '10115',
+ ];
+}
+
+/**
+ * Create a cart with the variant and a started checkout.
+ *
+ * @return array{0: App\Models\Cart, 1: App\Models\Checkout}
+ */
+function startedCheckout(Store $store, ProductVariant $variant, int $quantity = 2): array
+{
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'jane@example.com');
+
+ return [$cart->refresh(), $checkout];
+}
+
+test('transitions from started to addressed with valid address', function () {
+ [$store, $variant] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant);
+
+ $checkout = app(CheckoutService::class)->setAddress($checkout, ['shipping_address' => stateAddress()]);
+
+ expect($checkout->status)->toBe(CheckoutStatus::Addressed)
+ ->and($checkout->shipping_address_json['city'])->toBe('Berlin')
+ ->and($checkout->billing_address_json['city'])->toBe('Berlin');
+});
+
+test('rejects address transition with missing required fields', function () {
+ [$store, $variant] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant);
+
+ $response = $this->putJson(checkoutStateApiUrl($store, "/checkouts/{$checkout->id}/address"), [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+
+ $response->assertUnprocessable()
+ ->assertJsonValidationErrors(['shipping_address.city']);
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::Started);
+});
+
+test('transitions from addressed to shipping selected', function () {
+ [$store, $variant, $rate] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant);
+ $service = app(CheckoutService::class);
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
+ ->and($checkout->shipping_method_id)->toBe($rate->id)
+ ->and($checkout->totals_json['shipping'])->toBe(499);
+});
+
+test('rejects shipping selection with rate from wrong zone', function () {
+ [$store, $variant] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant);
+ $service = app(CheckoutService::class);
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress('DE')]);
+
+ $usZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['US']]);
+ $usRate = ShippingRate::factory()->flat(999)->create(['zone_id' => $usZone->id]);
+
+ $service->setShippingMethod($checkout, $usRate->id);
+})->throws(ValidationException::class);
+
+test('skips shipping selection when no items require shipping', function () {
+ [$store, $variant] = checkoutStateSetup(['requires_shipping' => false]);
+ [, $checkout] = startedCheckout($store, $variant);
+ $service = app(CheckoutService::class);
+
+ // Digital-only carts are serviceable without a zone match.
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress('FR')]);
+ $checkout = $service->setShippingMethod($checkout, null);
+
+ expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected)
+ ->and($checkout->shipping_method_id)->toBeNull()
+ ->and($checkout->totals_json['shipping'])->toBe(0);
+});
+
+test('transitions from shipping selected to payment selected', function () {
+ [$store, $variant, $rate] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant, quantity: 3);
+ $service = app(CheckoutService::class);
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'paypal');
+
+ expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected)
+ ->and($checkout->expires_at->isFuture())->toBeTrue()
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(3);
+});
+
+test('rejects invalid state transitions', function () {
+ [$store, $variant] = checkoutStateSetup();
+ [, $checkout] = startedCheckout($store, $variant);
+
+ app(CheckoutService::class)->selectPaymentMethod($checkout, 'credit_card');
+})->throws(InvalidCheckoutTransitionException::class);
+
+test('recalculates pricing on address change', function () {
+ [$store, $variant, $rate] = checkoutStateSetup();
+
+ TaxSettings::factory()->withRate(1900)->create([
+ 'store_id' => $store->id,
+ 'config_json' => [
+ 'default_rate_bps' => 1900,
+ 'zone_rates' => [],
+ ],
+ ]);
+
+ $frZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['FR']]);
+ ShippingRate::factory()->flat(499)->create(['zone_id' => $frZone->id]);
+
+ // Different rate for the FR zone.
+ $taxSettings = TaxSettings::find($store->id);
+ $config = $taxSettings->config_json;
+ $deZone = ShippingZone::where('store_id', $store->id)->where('countries_json', 'like', '%DE%')->first();
+ $config['zone_rates'] = [$deZone->id => 1900, $frZone->id => 700];
+ $taxSettings->update(['config_json' => $config]);
+
+ $service = app(CheckoutService::class);
+ [, $checkout] = startedCheckout($store, $variant, quantity: 2);
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress('DE')]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $taxDe = $checkout->totals_json['tax'];
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => stateAddress('FR')]);
+ $frRate = $frZone->rates()->first();
+ $checkout = $service->setShippingMethod($checkout, $frRate->id);
+ $taxFr = $checkout->totals_json['tax'];
+
+ // 19% of 5499 = 1044 vs 7% of 5499 = 384
+ expect($taxDe)->toBe(1044)
+ ->and($taxFr)->toBe(384);
+});
diff --git a/tests/Feature/Checkout/DiscountTest.php b/tests/Feature/Checkout/DiscountTest.php
new file mode 100644
index 00000000..edebd2d5
--- /dev/null
+++ b/tests/Feature/Checkout/DiscountTest.php
@@ -0,0 +1,142 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with variant (2500), DE zone + flat rate 499, and a checkout in
+ * shipping_selected state for a 2x cart (subtotal 5000).
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate, 3: App\Models\Checkout}
+ */
+function discountSetup(): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $service = app(CheckoutService::class);
+ $checkout = $service->createFromCart($cart->refresh(), 'jane@example.com');
+ $checkout = $service->setAddress($checkout, ['shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ]]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ return [$store, $variant, $rate, $checkout];
+}
+
+test('applies a valid percent discount code at checkout', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->percent(10)->create(['store_id' => $store->id, 'code' => 'SAVE10']);
+
+ $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => 'SAVE10'])
+ ->assertOk()
+ ->assertJsonPath('discount_code', 'SAVE10')
+ ->assertJsonPath('totals.subtotal', 5000)
+ ->assertJsonPath('totals.discount', 500)
+ ->assertJsonPath('applied_discounts.0.code', 'SAVE10')
+ ->assertJsonPath('applied_discounts.0.type', 'percent')
+ ->assertJsonPath('applied_discounts.0.applied_amount', 500);
+});
+
+test('applies a valid fixed discount code at checkout', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->fixed(500)->create(['store_id' => $store->id, 'code' => '5OFF']);
+
+ $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => '5OFF'])
+ ->assertOk()
+ ->assertJsonPath('totals.discount', 500);
+});
+
+test('removes discount when code is cleared', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->percent(10)->create(['store_id' => $store->id, 'code' => 'SAVE10']);
+ $service = app(CheckoutService::class);
+ $service->applyDiscount($checkout, 'SAVE10');
+
+ expect($checkout->refresh()->totals_json['discount'])->toBe(500);
+
+ $this->deleteJson(discountApiUrl($store, "/checkouts/{$checkout->id}/discount"))
+ ->assertOk()
+ ->assertJsonPath('discount_code', null)
+ ->assertJsonPath('totals.discount', 0);
+});
+
+test('rejects expired discount at checkout', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->expired()->create(['store_id' => $store->id, 'code' => 'OLD10']);
+
+ $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => 'OLD10'])
+ ->assertStatus(400)
+ ->assertJsonPath('error_code', 'discount_expired');
+});
+
+test('rejects a discount that reached its usage limit', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->maxedOut()->create(['store_id' => $store->id, 'code' => 'MAXED']);
+
+ $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => 'MAXED'])
+ ->assertStatus(400)
+ ->assertJsonPath('error_code', 'discount_usage_limit_reached');
+});
+
+test('rejects an unknown discount code', function () {
+ [$store, , , $checkout] = discountSetup();
+
+ $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => 'NOPE'])
+ ->assertUnprocessable()
+ ->assertJsonPath('error_code', 'discount_not_found');
+});
+
+test('handles free shipping discount at checkout', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->freeShipping()->create(['store_id' => $store->id, 'code' => 'FREESHIP']);
+
+ $response = $this->postJson(discountApiUrl($store, "/checkouts/{$checkout->id}/apply-discount"), ['code' => 'FREESHIP']);
+
+ $response->assertOk()
+ ->assertJsonPath('totals.shipping', 0)
+ ->assertJsonPath('shipping_method_id', $checkout->shipping_method_id);
+});
+
+test('applies automatic discounts stacked on the remaining amount', function () {
+ [$store, , , $checkout] = discountSetup();
+ Discount::factory()->automatic()->percent(10)->create(['store_id' => $store->id]);
+ Discount::factory()->automatic()->percent(10)->create(['store_id' => $store->id]);
+
+ app(CheckoutService::class)->recalculate($checkout->refresh());
+
+ // 10% of 5000 = 500, then 10% of remaining 4500 = 450 -> 950 total.
+ expect($checkout->refresh()->totals_json['discount'])->toBe(950);
+});
diff --git a/tests/Feature/Checkout/PricingIntegrationTest.php b/tests/Feature/Checkout/PricingIntegrationTest.php
new file mode 100644
index 00000000..dcb7aba9
--- /dev/null
+++ b/tests/Feature/Checkout/PricingIntegrationTest.php
@@ -0,0 +1,149 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with variant, DE zone + flat rate 499, 19% exclusive tax.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate}
+ */
+function pricingSetup(int $price = 2500, bool $inclusive = false): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => $price,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ TaxSettings::factory()->withRate(1900)->create([
+ 'store_id' => $store->id,
+ 'prices_include_tax' => $inclusive,
+ ]);
+
+ return [$store, $variant, $rate];
+}
+
+function integrationAddress(): array
+{
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ];
+}
+
+/**
+ * Drive a checkout through address + shipping selection.
+ *
+ * @return array{0: App\Models\Checkout, 1: App\Models\Cart}
+ */
+function checkoutThroughShipping(Store $store, ProductVariant $variant, ShippingRate $rate, int $quantity = 2, ?int $price = null): array
+{
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $service = app(CheckoutService::class);
+ $checkout = $service->createFromCart($cart->refresh(), 'jane@example.com');
+ $checkout = $service->setAddress($checkout, ['shipping_address' => integrationAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ return [$checkout, $cart->refresh()];
+}
+
+test('calculates correct totals for a simple checkout', function () {
+ [$store, $variant, $rate] = pricingSetup();
+
+ [$checkout] = checkoutThroughShipping($store, $variant, $rate, quantity: 2);
+
+ // 2500 x 2 = 5000, flat 499, 19% exclusive on 5499 -> 1044 (spec 09).
+ expect($checkout->totals_json['subtotal'])->toBe(5000)
+ ->and($checkout->totals_json['shipping'])->toBe(499)
+ ->and($checkout->totals_json['tax'])->toBe(1044)
+ ->and($checkout->totals_json['total'])->toBe(6543);
+});
+
+test('applies discount code and recalculates', function () {
+ [$store, $variant, $rate] = pricingSetup(price: 10000);
+ \App\Models\Discount::factory()->percent(10)->create(['store_id' => $store->id, 'code' => 'SAVE10']);
+
+ [$checkout] = checkoutThroughShipping($store, $variant, $rate, quantity: 1);
+
+ $result = app(CheckoutService::class)->applyDiscount($checkout, 'SAVE10');
+ $checkout->refresh();
+
+ expect($result->valid)->toBeTrue()
+ ->and($checkout->totals_json['subtotal'])->toBe(10000)
+ ->and($checkout->totals_json['discount'])->toBe(1000)
+ ->and($checkout->totals_json['subtotal'] - $checkout->totals_json['discount'])->toBe(9000);
+});
+
+test('stores pricing snapshot in totals_json', function () {
+ [$store, $variant, $rate] = pricingSetup();
+
+ [$checkout] = checkoutThroughShipping($store, $variant, $rate);
+
+ expect($checkout->totals_json)->toHaveKeys(['subtotal', 'discount', 'shipping', 'tax', 'tax_lines', 'total', 'currency'])
+ ->and($checkout->totals_json['tax_lines'][0])->toHaveKeys(['name', 'rate', 'amount']);
+});
+
+test('recalculates on shipping method change', function () {
+ [$store, $variant, $rate] = pricingSetup();
+ $zone = $rate->zone;
+ $weightRate = ShippingRate::factory()->weight([
+ ['min_g' => 0, 'max_g' => 5000, 'amount' => 899],
+ ])->create(['zone_id' => $zone->id]);
+
+ $service = app(CheckoutService::class);
+ [$checkout] = checkoutThroughShipping($store, $variant, $rate);
+
+ expect($checkout->totals_json['shipping'])->toBe(499)
+ ->and($checkout->totals_json['total'])->toBe(6543);
+
+ $checkout = $service->setShippingMethod($checkout, $weightRate->id);
+
+ // 19% exclusive: 950 on lines + 170 on shipping = 1120.
+ expect($checkout->totals_json['shipping'])->toBe(899)
+ ->and($checkout->totals_json['tax'])->toBe(1120)
+ ->and($checkout->totals_json['total'])->toBe(5000 + 899 + 1120);
+});
+
+test('handles prices include tax correctly', function () {
+ [$store, $variant, $rate] = pricingSetup(price: 11900, inclusive: true);
+
+ [$checkout] = checkoutThroughShipping($store, $variant, $rate, quantity: 1);
+
+ // Gross 11900 @ 19%: extracted tax 1900, net subtotal 10000 (spec 09).
+ $lineTax = $checkout->totals_json['tax'] - 0;
+ $shippingTax = $checkout->tax_provider_snapshot_json['shipping_tax_amount'];
+ $itemTax = $checkout->tax_provider_snapshot_json['lines'][0]['tax_amount'];
+
+ expect($itemTax)->toBe(1900)
+ ->and(11900 - $itemTax)->toBe(10000)
+ ->and($checkout->totals_json['tax'])->toBe($itemTax + $shippingTax)
+ ->and($checkout->totals_json['total'])->toBe(11900 + 499);
+});
diff --git a/tests/Feature/Checkout/ShippingTest.php b/tests/Feature/Checkout/ShippingTest.php
new file mode 100644
index 00000000..b582b55f
--- /dev/null
+++ b/tests/Feature/Checkout/ShippingTest.php
@@ -0,0 +1,123 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with a shippable variant (2500, 250g each), DE zone + flat rate 499.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate, 3: App\Models\Checkout}
+ */
+function shippingSetup(array $variantAttributes = []): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create(array_merge([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ 'weight_g' => 250,
+ ], $variantAttributes));
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 3);
+
+ $checkout = app(CheckoutService::class)->createFromCart($cart->refresh(), 'jane@example.com');
+
+ return [$store, $variant, $rate, $checkout];
+}
+
+function shippingAddressFor(string $country): array
+{
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => $country,
+ 'country_code' => $country,
+ 'postal_code' => '10115',
+ ];
+}
+
+test('returns available shipping rates for address', function () {
+ [$store, , $rate, $checkout] = shippingSetup();
+
+ $response = $this->putJson(shippingApiUrl($store, "/checkouts/{$checkout->id}/address"), [
+ 'shipping_address' => shippingAddressFor('DE'),
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('status', 'addressed')
+ ->assertJsonPath('available_shipping_methods.0.id', $rate->id)
+ ->assertJsonPath('available_shipping_methods.0.name', 'Standard Shipping')
+ ->assertJsonPath('available_shipping_methods.0.type', 'flat')
+ ->assertJsonPath('available_shipping_methods.0.price_amount', 499)
+ ->assertJsonStructure(['available_shipping_methods' => [['currency', 'estimated_days_min', 'estimated_days_max']]]);
+});
+
+test('returns empty when no zone matches address', function () {
+ [$store, , , $checkout] = shippingSetup();
+
+ $this->putJson(shippingApiUrl($store, "/checkouts/{$checkout->id}/address"), [
+ 'shipping_address' => shippingAddressFor('FR'),
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['shipping_address']);
+});
+
+test('calculates flat rate correctly', function () {
+ [$store, , $rate, $checkout] = shippingSetup();
+ $service = app(CheckoutService::class);
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => shippingAddressFor('DE')]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ expect($checkout->totals_json['shipping'])->toBe(499);
+});
+
+test('calculates weight based rate correctly', function () {
+ [$store, , , $checkout] = shippingSetup();
+ $service = app(CheckoutService::class);
+
+ $zone = ShippingZone::where('store_id', $store->id)->sole();
+ $weightRate = ShippingRate::factory()->weight([
+ ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
+ ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
+ ])->create(['zone_id' => $zone->id]);
+
+ // 3 x 250g = 750g -> second range.
+ $checkout = $service->setAddress($checkout, ['shipping_address' => shippingAddressFor('DE')]);
+ $checkout = $service->setShippingMethod($checkout, $weightRate->id);
+
+ expect($checkout->totals_json['shipping'])->toBe(899);
+});
+
+test('returns zero shipping when all items are digital', function () {
+ [$store, , , $checkout] = shippingSetup(['requires_shipping' => false]);
+ $service = app(CheckoutService::class);
+
+ $checkout = $service->setAddress($checkout, ['shipping_address' => shippingAddressFor('DE')]);
+ $checkout = $service->setShippingMethod($checkout, null);
+
+ expect($checkout->totals_json['shipping'])->toBe(0)
+ ->and($checkout->shipping_method_id)->toBeNull()
+ ->and($checkout->status->value)->toBe('shipping_selected');
+});
diff --git a/tests/Feature/Checkout/TaxTest.php b/tests/Feature/Checkout/TaxTest.php
new file mode 100644
index 00000000..72d7d39a
--- /dev/null
+++ b/tests/Feature/Checkout/TaxTest.php
@@ -0,0 +1,107 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => $price,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ if ($withTaxSettings) {
+ TaxSettings::factory()->withRate(1900)->create([
+ 'store_id' => $store->id,
+ 'prices_include_tax' => $inclusive,
+ ]);
+ }
+
+ return [$store, $variant, $rate];
+}
+
+/**
+ * Checkout at shipping_selected for the given quantity.
+ */
+function taxCheckout(Store $store, ProductVariant $variant, ShippingRate $rate, int $quantity): App\Models\Checkout
+{
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $service = app(CheckoutService::class);
+ $checkout = $service->createFromCart($cart->refresh(), 'jane@example.com');
+ $checkout = $service->setAddress($checkout, ['shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ]]);
+
+ return $service->setShippingMethod($checkout, $rate->id);
+}
+
+test('calculates exclusive tax correctly at checkout', function () {
+ [$store, $variant, $rate] = taxSetup();
+
+ // 5000 lines + 499 shipping = 5499 base -> 950 + 94 = 1044 (spec 09).
+ $checkout = taxCheckout($store, $variant, $rate, quantity: 2);
+
+ expect($checkout->totals_json['tax'])->toBe(1044)
+ ->and($checkout->totals_json['total'])->toBe(6543);
+});
+
+test('extracts inclusive tax correctly at checkout', function () {
+ [$store, $variant, $rate] = taxSetup(price: 11900, inclusive: true);
+
+ $checkout = taxCheckout($store, $variant, $rate, quantity: 1);
+
+ // Gross 11900 + 499 shipping; tax extracted from gross amounts.
+ $itemTax = $checkout->tax_provider_snapshot_json['lines'][0]['tax_amount'];
+ $shippingTax = $checkout->tax_provider_snapshot_json['shipping_tax_amount'];
+
+ expect($itemTax)->toBe(1900)
+ ->and(11900 - $itemTax)->toBe(10000)
+ ->and($checkout->totals_json['tax'])->toBe($itemTax + $shippingTax)
+ ->and($checkout->totals_json['total'])->toBe(11900 + 499);
+});
+
+test('applies zero tax when no tax settings exist', function () {
+ [$store, $variant, $rate] = taxSetup(withTaxSettings: false);
+
+ $checkout = taxCheckout($store, $variant, $rate, quantity: 2);
+
+ expect($checkout->totals_json['tax'])->toBe(0)
+ ->and($checkout->totals_json['total'])->toBe(5499);
+});
+
+test('stores tax lines in totals_json', function () {
+ [$store, $variant, $rate] = taxSetup();
+
+ $checkout = taxCheckout($store, $variant, $rate, quantity: 2);
+
+ expect($checkout->totals_json['tax_lines'])->toHaveCount(1)
+ ->and($checkout->totals_json['tax_lines'][0]['rate'])->toBe(1900)
+ ->and($checkout->totals_json['tax_lines'][0]['amount'])->toBe(1044)
+ ->and($checkout->totals_json['tax_lines'][0]['name'])->toBeString();
+});
diff --git a/tests/Feature/Storefront/BrowsingTest.php b/tests/Feature/Storefront/BrowsingTest.php
index bbf767f3..d887393a 100644
--- a/tests/Feature/Storefront/BrowsingTest.php
+++ b/tests/Feature/Storefront/BrowsingTest.php
@@ -186,7 +186,7 @@ function storefrontUrl(Store $store, string $path = '/'): string
->assertDontSee('Sold out');
});
-test('add to cart dispatches the event with variant id and quantity', function () {
+test('add to cart adds the line to the session cart and opens the drawer', function () {
$store = $this->createStore();
$this->bindStore($store);
@@ -195,7 +195,14 @@ function storefrontUrl(Store $store, string $path = '/'): string
Livewire::test(ProductPage::class, ['handle' => $product->handle])
->call('addToCart')
- ->assertDispatched('add-to-cart', variantId: $variant->id, quantity: 1);
+ ->assertDispatched('cart-updated', count: 1)
+ ->assertDispatched('cart-drawer-open');
+
+ $cart = \App\Models\Cart::query()->where('store_id', $store->id)->sole();
+
+ expect($cart->lines)->toHaveCount(1)
+ ->and($cart->lines->first()->variant_id)->toBe($variant->id)
+ ->and($cart->lines->first()->quantity)->toBe(1);
});
test('published page renders its title and sanitized body', function () {
diff --git a/tests/Feature/Storefront/CartUiTest.php b/tests/Feature/Storefront/CartUiTest.php
new file mode 100644
index 00000000..204d9df9
--- /dev/null
+++ b/tests/Feature/Storefront/CartUiTest.php
@@ -0,0 +1,184 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(50)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+/**
+ * A session-bound cart with one line of the variant.
+ *
+ * @return array{0: Cart, 1: App\Models\CartLine}
+ */
+function sessionCartWithLine(Store $store, ProductVariant $variant): array
+{
+ $service = app(CartService::class);
+ $cart = $service->create($store);
+ $line = $service->addLine($cart, $variant->id, 1);
+
+ session(['cart_id' => $cart->id]);
+
+ return [$cart->refresh(), $line];
+}
+
+test('cart drawer adds a line from the add-to-cart event', function () {
+ [$store, $variant] = cartUiSetup();
+
+ Livewire::test(CartDrawer::class)
+ ->dispatch('add-to-cart', variantId: $variant->id, quantity: 2)
+ ->assertDispatched('cart-updated', count: 2)
+ ->assertDispatched('cart-drawer-open');
+
+ $cart = Cart::query()->where('store_id', $store->id)->sole();
+
+ expect($cart->lines)->toHaveCount(1)
+ ->and($cart->lines->first()->quantity)->toBe(2);
+});
+
+test('cart page updates line quantity', function () {
+ [$store, $variant] = cartUiSetup();
+ [, $line] = sessionCartWithLine($store, $variant);
+
+ Livewire::test(CartPage::class)
+ ->call('incrementLine', $line->id)
+ ->assertDispatched('cart-updated', count: 2);
+
+ expect($line->refresh()->quantity)->toBe(2);
+
+ Livewire::test(CartPage::class)
+ ->call('decrementLine', $line->id)
+ ->call('decrementLine', $line->id);
+
+ expect(Cart::find(session('cart_id'))->lines)->toHaveCount(0);
+});
+
+test('cart page shows a discount error for an unknown code', function () {
+ [$store, $variant] = cartUiSetup();
+ sessionCartWithLine($store, $variant);
+
+ Livewire::test(CartPage::class)
+ ->set('discountCode', 'NOPE')
+ ->call('applyDiscount')
+ ->assertSet('discountError', 'This discount code is invalid.');
+});
+
+test('checkout new redirects to the cart when it is empty', function () {
+ cartUiSetup();
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => 'new'])
+ ->assertRedirect(route('storefront.cart.show'));
+});
+
+test('checkout walks from address step to shipping and payment', function () {
+ [$store, $variant, $rate] = cartUiSetup();
+ sessionCartWithLine($store, $variant);
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => 'new'])
+ ->set('email', 'jane@example.com')
+ ->set('address.first_name', 'Jane')
+ ->set('address.last_name', 'Doe')
+ ->set('address.address1', '123 Main St')
+ ->set('address.city', 'Berlin')
+ ->set('address.country_code', 'DE')
+ ->set('address.postal_code', '10115')
+ ->call('submitAddress');
+
+ $checkout = Checkout::query()->where('store_id', $store->id)->sole();
+
+ expect($checkout->status)->toBe(CheckoutStatus::Addressed)
+ ->and($checkout->email)->toBe('jane@example.com')
+ ->and($checkout->shipping_address_json['city'])->toBe('Berlin');
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => $checkout->id])
+ ->assertSet('step', 2)
+ ->call('selectShipping', $rate->id)
+ ->assertSet('step', 3);
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::ShippingSelected)
+ ->and($checkout->totals_json['shipping'])->toBe(499);
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => $checkout->id])
+ ->assertSet('step', 3)
+ ->set('paymentMethod', 'paypal')
+ ->call('selectPayment')
+ ->assertSet('paymentSelected', true);
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(1);
+});
+
+test('checkout validates the address form', function () {
+ [$store, $variant] = cartUiSetup();
+ sessionCartWithLine($store, $variant);
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => 'new'])
+ ->set('email', 'not-an-email')
+ ->call('submitAddress')
+ ->assertHasErrors(['email', 'address.first_name', 'address.address1', 'address.city', 'address.country_code', 'address.postal_code']);
+
+ expect(Checkout::query()->count())->toBe(0);
+});
+
+test('expired checkout renders the expired state', function () {
+ [$store, $variant] = cartUiSetup();
+ [$cart] = sessionCartWithLine($store, $variant);
+
+ $checkout = app(\App\Services\CheckoutService::class)->createFromCart($cart, 'jane@example.com');
+ $checkout->update(['expires_at' => now()->subHour()]);
+
+ Livewire::test(CheckoutPage::class, ['checkoutId' => $checkout->id])
+ ->assertSet('expired', true)
+ ->assertSee('This checkout has expired');
+});
+
+test('cart page renders over http with the drawer in the layout', function () {
+ [$store, $variant] = cartUiSetup();
+ [$cart] = sessionCartWithLine($store, $variant);
+
+ $this->withSession(['cart_id' => $cart->id])
+ ->get('http://'.$store->handle.'.test/cart')
+ ->assertOk()
+ ->assertSee('Your Cart')
+ ->assertSee('25.00 USD');
+});
+
+test('checkout new page renders the address step over http', function () {
+ [$store, $variant] = cartUiSetup();
+ [$cart] = sessionCartWithLine($store, $variant);
+
+ $this->withSession(['cart_id' => $cart->id])
+ ->get('http://'.$store->handle.'.test/checkout/new')
+ ->assertOk()
+ ->assertSee('Contact & shipping address');
+});
diff --git a/tests/Unit/CartVersionTest.php b/tests/Unit/CartVersionTest.php
new file mode 100644
index 00000000..52b9cfb3
--- /dev/null
+++ b/tests/Unit/CartVersionTest.php
@@ -0,0 +1,73 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(100)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $cart = app(CartService::class)->create($store);
+
+ return [$store, $variant, $cart];
+}
+
+test('starts at version 1', function () {
+ [, , $cart] = cartVersionSetup();
+
+ expect($cart->cart_version)->toBe(1);
+});
+
+test('increments version on add line', function () {
+ [, $variant, $cart] = cartVersionSetup();
+
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+
+ expect($cart->refresh()->cart_version)->toBe(2);
+});
+
+test('increments version on update quantity', function () {
+ [, $variant, $cart] = cartVersionSetup();
+ $service = app(CartService::class);
+ $line = $service->addLine($cart, $variant->id, 1);
+
+ $service->updateLineQuantity($cart->refresh(), $line->id, 3);
+
+ expect($cart->refresh()->cart_version)->toBe(3);
+});
+
+test('increments version on remove line', function () {
+ [, $variant, $cart] = cartVersionSetup();
+ $service = app(CartService::class);
+ $line = $service->addLine($cart, $variant->id, 1);
+
+ $service->removeLine($cart->refresh(), $line->id);
+
+ expect($cart->refresh()->cart_version)->toBe(3);
+});
+
+test('detects version mismatch', function () {
+ [, $variant, $cart] = cartVersionSetup();
+ $service = app(CartService::class);
+ $service->addLine($cart, $variant->id, 1);
+ $service->addLine($cart->refresh(), $variant->id, 1); // version 3
+
+ $service->assertVersion($cart->refresh(), 2);
+})->throws(CartVersionMismatchException::class);
diff --git a/tests/Unit/DiscountCalculatorTest.php b/tests/Unit/DiscountCalculatorTest.php
new file mode 100644
index 00000000..d109dc77
--- /dev/null
+++ b/tests/Unit/DiscountCalculatorTest.php
@@ -0,0 +1,190 @@
+create(['store_id' => $store->id, 'currency' => 'USD']);
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->create(['product_id' => $product->id, 'price_amount' => $subtotal]);
+
+ CartLine::create([
+ 'cart_id' => $cart->id,
+ 'variant_id' => $variant->id,
+ 'quantity' => 1,
+ 'unit_price_amount' => $subtotal,
+ 'line_subtotal_amount' => $subtotal,
+ 'line_discount_amount' => 0,
+ 'line_total_amount' => $subtotal,
+ ]);
+
+ return $cart->refresh();
+}
+
+/**
+ * Flat lines for direct calculate() calls.
+ *
+ * @param array $subtotals
+ * @return array>
+ */
+function discountLines(array $subtotals): array
+{
+ return array_map(fn (int $amount, int $index): array => [
+ 'product_id' => $index + 1,
+ 'collection_ids' => [],
+ 'line_subtotal_amount' => $amount,
+ ], $subtotals, array_keys($subtotals));
+}
+
+test('validates an active discount code', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+ Discount::factory()->create([
+ 'store_id' => $store->id,
+ 'code' => 'SAVE10',
+ 'starts_at' => now()->subDay(),
+ 'ends_at' => now()->addDay(),
+ ]);
+
+ $result = discountService()->validate('SAVE10', $store, $cart);
+
+ expect($result->valid)->toBeTrue()
+ ->and($result->discount)->toBeInstanceOf(Discount::class)
+ ->and($result->discount->code)->toBe('SAVE10');
+});
+
+test('rejects an expired discount code', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+ Discount::factory()->expired()->create(['store_id' => $store->id, 'code' => 'OLD10']);
+
+ $result = discountService()->validate('OLD10', $store, $cart);
+
+ expect($result->valid)->toBeFalse()
+ ->and($result->errorCode)->toBe('discount_expired');
+});
+
+test('rejects a not-yet-active discount code', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+ Discount::factory()->create([
+ 'store_id' => $store->id,
+ 'code' => 'FUTURE10',
+ 'starts_at' => now()->addDay(),
+ 'ends_at' => null,
+ ]);
+
+ $result = discountService()->validate('FUTURE10', $store, $cart);
+
+ expect($result->valid)->toBeFalse()
+ ->and($result->errorCode)->toBe('discount_not_yet_active');
+});
+
+test('rejects a discount that has reached its usage limit', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+ Discount::factory()->maxedOut()->create(['store_id' => $store->id, 'code' => 'MAXED10']);
+
+ $result = discountService()->validate('MAXED10', $store, $cart);
+
+ expect($result->valid)->toBeFalse()
+ ->and($result->errorCode)->toBe('discount_usage_limit_reached');
+});
+
+test('rejects an unknown discount code', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+
+ $result = discountService()->validate('DOESNOTEXIST', $store, $cart);
+
+ expect($result->valid)->toBeFalse()
+ ->and($result->errorCode)->toBe('discount_not_found');
+});
+
+test('performs case-insensitive code lookup', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 10000);
+ Discount::factory()->create(['store_id' => $store->id, 'code' => 'SUMMER20']);
+
+ expect(discountService()->validate('summer20', $store, $cart)->valid)->toBeTrue();
+});
+
+test('enforces minimum purchase amount rule', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 3000);
+ Discount::factory()->withMinPurchase(5000)->create(['store_id' => $store->id, 'code' => 'MIN50']);
+
+ $result = discountService()->validate('MIN50', $store, $cart);
+
+ expect($result->valid)->toBeFalse()
+ ->and($result->errorCode)->toBe('discount_min_purchase_not_met');
+});
+
+test('passes minimum purchase when cart meets threshold', function () {
+ $store = $this->createStore();
+ $cart = cartWithSubtotal($store, 5000);
+ Discount::factory()->withMinPurchase(5000)->create(['store_id' => $store->id, 'code' => 'MIN50']);
+
+ expect(discountService()->validate('MIN50', $store, $cart)->valid)->toBeTrue();
+});
+
+test('calculates percent discount amount', function () {
+ $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 15, 'rules_json' => []]);
+
+ $result = discountService()->calculate($discount, 10000, discountLines([10000]));
+
+ expect($result['amount'])->toBe(1500);
+});
+
+test('calculates fixed discount amount', function () {
+ $discount = new Discount(['value_type' => DiscountValueType::Fixed, 'value_amount' => 500, 'rules_json' => []]);
+
+ $result = discountService()->calculate($discount, 10000, discountLines([10000]));
+
+ expect($result['amount'])->toBe(500);
+});
+
+test('handles free shipping discount type', function () {
+ $discount = new Discount(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0, 'rules_json' => []]);
+
+ $result = discountService()->calculate($discount, 10000, discountLines([10000]));
+
+ expect($result['amount'])->toBe(0)
+ ->and($result['free_shipping'])->toBeTrue();
+});
+
+test('allocates discount proportionally across multiple lines', function () {
+ $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'rules_json' => []]);
+
+ $result = discountService()->calculate($discount, 10000, discountLines([7500, 2500]));
+
+ expect($result['amount'])->toBe(1000)
+ ->and($result['allocations'][0])->toBe(750)
+ ->and($result['allocations'][1])->toBe(250);
+});
+
+test('distributes rounding remainder to the last qualifying line', function () {
+ $discount = new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => 10, 'rules_json' => []]);
+
+ $result = discountService()->calculate($discount, 10000, discountLines([3333, 3333, 3334]));
+
+ expect($result['amount'])->toBe(1000)
+ ->and(array_sum($result['allocations']))->toBe(1000)
+ ->and($result['allocations'][2])->toBe(1000 - $result['allocations'][0] - $result['allocations'][1]);
+});
diff --git a/tests/Unit/PricingEngineTest.php b/tests/Unit/PricingEngineTest.php
new file mode 100644
index 00000000..dc0e495c
--- /dev/null
+++ b/tests/Unit/PricingEngineTest.php
@@ -0,0 +1,207 @@
+ $overrides
+ * @return array
+ */
+function pricingLine(int $unitPrice, int $quantity, array $overrides = []): array
+{
+ return array_merge([
+ 'variant_id' => 1,
+ 'product_id' => 1,
+ 'collection_ids' => [],
+ 'quantity' => $quantity,
+ 'unit_price_amount' => $unitPrice,
+ 'requires_shipping' => true,
+ ], $overrides);
+}
+
+function pricingTaxSettings(bool $inclusive = false, int $rateBps = 1900): TaxSettings
+{
+ return new TaxSettings([
+ 'mode' => TaxMode::Manual,
+ 'prices_include_tax' => $inclusive,
+ 'config_json' => ['default_rate_bps' => $rateBps],
+ ]);
+}
+
+function pricingAddress(): Address
+{
+ return Address::fromArray(['country_code' => 'DE']);
+}
+
+function pricingEngine(): PricingEngine
+{
+ return app(PricingEngine::class);
+}
+
+function percentDiscount(int $value): Discount
+{
+ return new Discount(['value_type' => DiscountValueType::Percent, 'value_amount' => $value, 'rules_json' => []]);
+}
+
+function fixedDiscount(int $amount): Discount
+{
+ return new Discount(['value_type' => DiscountValueType::Fixed, 'value_amount' => $amount, 'rules_json' => []]);
+}
+
+function flatShipping(int $amount): ShippingRateVO
+{
+ return new ShippingRateVO(id: 1, name: 'Flat', amount: $amount, type: ShippingRateType::Flat);
+}
+
+test('calculates subtotal from line items', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(2499, 2), pricingLine(7999, 1)],
+ null, [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->subtotal)->toBe(12997);
+});
+
+test('calculates subtotal for a single line', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(1500, 3)],
+ null, [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->subtotal)->toBe(4500);
+});
+
+test('returns zero subtotal for empty cart', function () {
+ $result = pricingEngine()->calculate(
+ [],
+ null, [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->subtotal)->toBe(0)
+ ->and($result->total)->toBe(0);
+});
+
+test('applies percent discount correctly', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(10000, 1)],
+ percentDiscount(10), [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->discount)->toBe(1000)
+ ->and($result->subtotal - $result->discount)->toBe(9000);
+});
+
+test('applies fixed discount correctly', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(10000, 1)],
+ fixedDiscount(500), [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->discount)->toBe(500)
+ ->and($result->subtotal - $result->discount)->toBe(9500);
+});
+
+test('caps fixed discount at subtotal so it never goes negative', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(300, 1)],
+ fixedDiscount(500), [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->discount)->toBe(300)
+ ->and($result->subtotal - $result->discount)->toBe(0);
+});
+
+test('applies free shipping discount by zeroing shipping', function () {
+ $freeShipping = new Discount(['value_type' => DiscountValueType::FreeShipping, 'value_amount' => 0, 'rules_json' => []]);
+
+ $result = pricingEngine()->calculate(
+ [pricingLine(5000, 1)],
+ $freeShipping, [], flatShipping(499), pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->shipping)->toBe(0);
+});
+
+test('calculates tax exclusive correctly', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(10000, 1)],
+ null, [], null, pricingTaxSettings(inclusive: false, rateBps: 1900), pricingAddress(), 'USD',
+ );
+
+ expect($result->taxTotal)->toBe(1900)
+ ->and($result->total)->toBe(11900);
+});
+
+test('extracts tax from inclusive price correctly', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(11900, 1)],
+ null, [], null, pricingTaxSettings(inclusive: true, rateBps: 1900), pricingAddress(), 'USD',
+ );
+
+ expect($result->taxTotal)->toBe(1900)
+ ->and($result->subtotal - $result->taxTotal)->toBe(1000 * 10) // net = 10000
+ ->and($result->total)->toBe(11900);
+});
+
+test('returns zero tax when rate is zero', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(10000, 1)],
+ null, [], null, pricingTaxSettings(rateBps: 0), pricingAddress(), 'USD',
+ );
+
+ expect($result->taxTotal)->toBe(0)
+ ->and($result->taxLines)->toBeEmpty();
+});
+
+test('calculates shipping flat rate', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(5000, 1)],
+ null, [], flatShipping(499), pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->shipping)->toBe(499);
+});
+
+test('calculates full checkout totals end to end', function () {
+ // 2 lines x 2499 = 4998, 10% code, flat shipping 499, 19% tax inclusive.
+ $result = pricingEngine()->calculate(
+ [pricingLine(2499, 1), pricingLine(2499, 1)],
+ percentDiscount(10), [], flatShipping(499), pricingTaxSettings(inclusive: true, rateBps: 1900), pricingAddress(), 'USD',
+ );
+
+ expect($result->subtotal)->toBe(4998)
+ ->and($result->discount)->toBe(499)
+ ->and($result->shipping)->toBe(499)
+ ->and($result->taxTotal)->toBe(800) // extracted from post-discount gross
+ ->and($result->total)->toBe(4998);
+});
+
+test('handles rounding correctly with odd cent amounts', function () {
+ $result = pricingEngine()->calculate(
+ [pricingLine(3333, 1), pricingLine(3333, 1), pricingLine(3334, 1)],
+ percentDiscount(10), [], null, pricingTaxSettings(), null, 'USD',
+ );
+
+ expect($result->discount)->toBe(1000)
+ ->and(array_sum($result->lineDiscounts))->toBe(1000);
+});
+
+test('produces identical results for identical inputs', function () {
+ $run = fn (): PricingResult => pricingEngine()->calculate(
+ [pricingLine(2499, 2), pricingLine(7999, 1)],
+ percentDiscount(15), [], flatShipping(899), pricingTaxSettings(inclusive: false, rateBps: 700), pricingAddress(), 'USD',
+ );
+
+ expect($run()->toArray())->toBe($run()->toArray());
+});
diff --git a/tests/Unit/ShippingCalculatorTest.php b/tests/Unit/ShippingCalculatorTest.php
new file mode 100644
index 00000000..b05bd793
--- /dev/null
+++ b/tests/Unit/ShippingCalculatorTest.php
@@ -0,0 +1,179 @@
+ $countryCode,
+ 'country_code' => $countryCode,
+ 'province_code' => $provinceCode,
+ ]);
+}
+
+/**
+ * Build an in-memory cart with lines carrying variant weight/shipping data.
+ *
+ * @param array> $lines
+ */
+function shippingCart(array $lines): Cart
+{
+ $cart = new Cart(['currency' => 'USD']);
+
+ $cart->setRelation('lines', collect($lines)->map(function (array $data): CartLine {
+ $quantity = $data['quantity'] ?? 1;
+ $unitPrice = $data['unit_price_amount'] ?? 0;
+
+ $line = new CartLine([
+ 'quantity' => $quantity,
+ 'unit_price_amount' => $unitPrice,
+ 'line_subtotal_amount' => $unitPrice * $quantity,
+ 'line_discount_amount' => 0,
+ 'line_total_amount' => $unitPrice * $quantity,
+ ]);
+
+ $line->setRelation('variant', new ProductVariant([
+ 'weight_g' => $data['weight_g'] ?? null,
+ 'requires_shipping' => $data['requires_shipping'] ?? true,
+ ]));
+
+ return $line;
+ }));
+
+ return $cart;
+}
+
+/**
+ * Build an in-memory shipping rate.
+ *
+ * @param array $config
+ */
+function shippingRate(ShippingRateType $type, array $config, bool $active = true): ShippingRate
+{
+ return new ShippingRate([
+ 'name' => 'Test Rate',
+ 'type' => $type,
+ 'config_json' => $config,
+ 'is_active' => $active,
+ ]);
+}
+
+test('matches a zone by country code', function () {
+ $store = $this->createStore();
+ $zone = ShippingZone::factory()->create([
+ 'store_id' => $store->id,
+ 'countries_json' => ['DE', 'AT', 'CH'],
+ 'regions_json' => [],
+ ]);
+
+ expect(shippingCalculator()->getMatchingZone($store, addressFor('DE'))?->id)->toBe($zone->id);
+});
+
+test('matches a zone by region code', function () {
+ $store = $this->createStore();
+ $zone = ShippingZone::factory()->create([
+ 'store_id' => $store->id,
+ 'countries_json' => ['US'],
+ 'regions_json' => ['US-NY', 'US-CA'],
+ ]);
+
+ expect(shippingCalculator()->getMatchingZone($store, addressFor('US', 'US-NY'))?->id)->toBe($zone->id);
+});
+
+test('returns empty when no zone matches the address', function () {
+ $store = $this->createStore();
+ ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+
+ $calculator = shippingCalculator();
+ $cart = shippingCart([['unit_price_amount' => 1000]]);
+
+ expect($calculator->getMatchingZone($store, addressFor('FR')))->toBeNull()
+ ->and($calculator->getAvailableRates($store, addressFor('FR'), $cart))->toBeEmpty();
+});
+
+test('calculates a flat rate', function () {
+ $rate = shippingRate(ShippingRateType::Flat, ['amount' => 499]);
+
+ expect(shippingCalculator()->calculate($rate, shippingCart([['unit_price_amount' => 1000]])))->toBe(499);
+});
+
+test('calculates a weight-based rate', function () {
+ $rate = shippingRate(ShippingRateType::Weight, [
+ 'ranges' => [
+ ['min_g' => 0, 'max_g' => 500, 'amount' => 499],
+ ['min_g' => 501, 'max_g' => 2000, 'amount' => 899],
+ ],
+ ]);
+
+ $cart = shippingCart([['weight_g' => 250, 'quantity' => 3]]); // 750g
+
+ expect(shippingCalculator()->calculate($rate, $cart))->toBe(899);
+});
+
+test('calculates a price-based rate', function () {
+ $rate = shippingRate(ShippingRateType::Price, [
+ 'ranges' => [
+ ['min_amount' => 0, 'max_amount' => 5000, 'amount' => 799],
+ ['min_amount' => 5001, 'max_amount' => 999999, 'amount' => 399],
+ ],
+ ]);
+
+ $cart = shippingCart([['unit_price_amount' => 2500, 'quantity' => 3]]); // 7500
+
+ expect(shippingCalculator()->calculate($rate, $cart))->toBe(399);
+});
+
+test('returns zero shipping when no items require shipping', function () {
+ $store = $this->createStore();
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ $cart = shippingCart([['unit_price_amount' => 1000, 'requires_shipping' => false]]);
+
+ expect(shippingCalculator()->getAvailableRates($store, addressFor('DE'), $cart))->toBeEmpty();
+});
+
+test('returns the rates of the winning zone when multiple zones match', function () {
+ $store = $this->createStore();
+
+ // Both zones match country DE; the lowest zone id wins the tie-break.
+ $firstZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $secondZone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE', 'AT']]);
+
+ ShippingRate::factory()->flat(499)->create(['zone_id' => $firstZone->id, 'name' => 'First Zone Rate']);
+ ShippingRate::factory()->flat(899)->create(['zone_id' => $secondZone->id, 'name' => 'Second Zone Rate']);
+
+ $matched = shippingCalculator()->getMatchingZone($store, addressFor('DE'));
+ $rates = shippingCalculator()->getAvailableRates($store, addressFor('DE'), shippingCart([['unit_price_amount' => 1000]]));
+
+ expect($matched->id)->toBe($firstZone->id)
+ ->and($rates)->toHaveCount(1)
+ ->and($rates->first()->name)->toBe('First Zone Rate')
+ ->and($rates->first()->amount)->toBe(499);
+});
+
+test('skips inactive rates', function () {
+ $store = $this->createStore();
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ ShippingRate::factory()->flat(499)->inactive()->create(['zone_id' => $zone->id]);
+ ShippingRate::factory()->flat(899)->create(['zone_id' => $zone->id, 'name' => 'Active Rate']);
+
+ $rates = shippingCalculator()->getAvailableRates($store, addressFor('DE'), shippingCart([['unit_price_amount' => 1000]]));
+
+ expect($rates)->toHaveCount(1)
+ ->and($rates->first()->name)->toBe('Active Rate');
+});
diff --git a/tests/Unit/TaxCalculatorTest.php b/tests/Unit/TaxCalculatorTest.php
new file mode 100644
index 00000000..6a58b110
--- /dev/null
+++ b/tests/Unit/TaxCalculatorTest.php
@@ -0,0 +1,46 @@
+addExclusive(10000, 1900))->toBe(1900);
+});
+
+test('extracts manual tax from inclusive amount', function () {
+ $tax = taxCalculator()->extractInclusive(11900, 1900);
+
+ expect($tax)->toBe(1900)
+ ->and(11900 - $tax)->toBe(10000); // net
+});
+
+test('returns zero tax when no rate is configured', function () {
+ expect(taxCalculator()->addExclusive(10000, 0))->toBe(0);
+});
+
+test('handles zero amount lines', function () {
+ expect(taxCalculator()->addExclusive(0, 1900))->toBe(0)
+ ->and(taxCalculator()->extractInclusive(0, 1900))->toBe(0);
+});
+
+test('calculates tax with non-standard rate', function () {
+ // 8999 * 700 / 10000 = 629.93 -> 629 (integer truncation, spec 09)
+ expect(taxCalculator()->addExclusive(8999, 700))->toBe(629);
+});
+
+test('extracts tax correctly for small amounts', function () {
+ $tax = taxCalculator()->extractInclusive(119, 1900);
+
+ expect($tax)->toBe(19)
+ ->and(119 - $tax)->toBe(100);
+});
+
+test('handles high tax rates', function () {
+ expect(taxCalculator()->addExclusive(10000, 2500))->toBe(2500);
+});
From b55fd22edcfccbbc4b2eaa036b9d9dff69677598 Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 16:10:49 +0200
Subject: [PATCH 07/29] Phase 5: Payments, orders, fulfillment, customers
- Models + factories: Customer (full), CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine
- MockPaymentProvider (magic cards, PayPal, deferred bank transfer) + PaymentService
- OrderService: atomic order creation, sequential numbering, idempotency, guest linking, digital auto-fulfillment, bank-transfer confirmation
- RefundService, FulfillmentService (payment guard)
- 9 domain events + audit-log listener + inventory release listener
- completeCheckout + /pay API + order status API (HMAC token)
- Checkout payment step UI + confirmation page
- CancelUnpaidBankTransferOrders job (daily)
- 75 new tests: 294 passed total
---
app/Contracts/PaymentProvider.php | 28 ++
app/Events/CheckoutCompleted.php | 14 +
app/Events/FulfillmentCreated.php | 14 +
app/Events/FulfillmentDelivered.php | 14 +
app/Events/FulfillmentShipped.php | 14 +
app/Events/OrderCancelled.php | 17 +
app/Events/OrderCreated.php | 14 +
app/Events/OrderFulfilled.php | 14 +
app/Events/OrderPaid.php | 14 +
app/Events/OrderRefunded.php | 18 +
app/Exceptions/FulfillmentGuardException.php | 18 +
.../InvalidOrderTransitionException.php | 17 +
app/Exceptions/PaymentFailedException.php | 19 +
.../Api/Storefront/CheckoutController.php | 89 +++++
.../Api/Storefront/OrderController.php | 72 ++++
app/Jobs/CancelUnpaidBankTransferOrders.php | 48 +++
app/Listeners/ReleaseOrderInventory.php | 42 +++
app/Listeners/WriteAuditLog.php | 66 ++++
.../Storefront/Checkout/Confirmation.php | 72 ++++
app/Livewire/Storefront/Checkout/Show.php | 64 +++-
app/Models/Customer.php | 31 ++
app/Models/CustomerAddress.php | 55 +++
app/Models/Fulfillment.php | 67 ++++
app/Models/FulfillmentLine.php | 63 ++++
app/Models/Order.php | 172 +++++++++
app/Models/OrderLine.php | 96 +++++
app/Models/Payment.php | 72 ++++
app/Models/Refund.php | 66 ++++
app/Providers/AppServiceProvider.php | 28 ++
app/Services/CheckoutService.php | 69 +++-
app/Services/FulfillmentService.php | 211 +++++++++++
app/Services/InventoryService.php | 8 +-
app/Services/OrderService.php | 320 +++++++++++++++++
app/Services/PaymentService.php | 83 +++++
app/Services/Payments/MockPaymentProvider.php | 78 ++++
app/Services/RefundService.php | 87 +++++
app/Support/OrderToken.php | 30 ++
app/Traits/ChecksStoreRole.php | 4 +
app/ValueObjects/PaymentResult.php | 48 +++
app/ValueObjects/RefundResult.php | 15 +
database/factories/CustomerAddressFactory.php | 50 +++
database/factories/FulfillmentFactory.php | 52 +++
database/factories/FulfillmentLineFactory.php | 27 ++
database/factories/OrderFactory.php | 116 ++++++
database/factories/OrderLineFactory.php | 36 ++
database/factories/PaymentFactory.php | 45 +++
database/factories/RefundFactory.php | 31 ++
...000001_add_checkout_id_to_orders_table.php | 34 ++
.../checkout/confirmation.blade.php | 122 +++++++
.../storefront/checkout/show.blade.php | 71 +++-
routes/api.php | 5 +
routes/console.php | 4 +
routes/web.php | 2 +
.../Checkout/CheckoutPaymentApiTest.php | 295 +++++++++++++++
tests/Feature/Checkout/OrderStatusApiTest.php | 117 ++++++
tests/Feature/Orders/FulfillmentTest.php | 224 ++++++++++++
tests/Feature/Orders/OrderCreationTest.php | 337 ++++++++++++++++++
tests/Feature/Orders/RefundTest.php | 146 ++++++++
.../Payments/BankTransferConfirmationTest.php | 195 ++++++++++
.../Payments/MockPaymentProviderTest.php | 78 ++++
tests/Feature/Payments/PaymentServiceTest.php | 148 ++++++++
61 files changed, 4387 insertions(+), 19 deletions(-)
create mode 100644 app/Contracts/PaymentProvider.php
create mode 100644 app/Events/CheckoutCompleted.php
create mode 100644 app/Events/FulfillmentCreated.php
create mode 100644 app/Events/FulfillmentDelivered.php
create mode 100644 app/Events/FulfillmentShipped.php
create mode 100644 app/Events/OrderCancelled.php
create mode 100644 app/Events/OrderCreated.php
create mode 100644 app/Events/OrderFulfilled.php
create mode 100644 app/Events/OrderPaid.php
create mode 100644 app/Events/OrderRefunded.php
create mode 100644 app/Exceptions/FulfillmentGuardException.php
create mode 100644 app/Exceptions/InvalidOrderTransitionException.php
create mode 100644 app/Exceptions/PaymentFailedException.php
create mode 100644 app/Http/Controllers/Api/Storefront/OrderController.php
create mode 100644 app/Jobs/CancelUnpaidBankTransferOrders.php
create mode 100644 app/Listeners/ReleaseOrderInventory.php
create mode 100644 app/Listeners/WriteAuditLog.php
create mode 100644 app/Livewire/Storefront/Checkout/Confirmation.php
create mode 100644 app/Models/CustomerAddress.php
create mode 100644 app/Models/Fulfillment.php
create mode 100644 app/Models/FulfillmentLine.php
create mode 100644 app/Models/Order.php
create mode 100644 app/Models/OrderLine.php
create mode 100644 app/Models/Payment.php
create mode 100644 app/Models/Refund.php
create mode 100644 app/Services/FulfillmentService.php
create mode 100644 app/Services/OrderService.php
create mode 100644 app/Services/PaymentService.php
create mode 100644 app/Services/Payments/MockPaymentProvider.php
create mode 100644 app/Services/RefundService.php
create mode 100644 app/Support/OrderToken.php
create mode 100644 app/ValueObjects/PaymentResult.php
create mode 100644 app/ValueObjects/RefundResult.php
create mode 100644 database/factories/CustomerAddressFactory.php
create mode 100644 database/factories/FulfillmentFactory.php
create mode 100644 database/factories/FulfillmentLineFactory.php
create mode 100644 database/factories/OrderFactory.php
create mode 100644 database/factories/OrderLineFactory.php
create mode 100644 database/factories/PaymentFactory.php
create mode 100644 database/factories/RefundFactory.php
create mode 100644 database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php
create mode 100644 resources/views/livewire/storefront/checkout/confirmation.blade.php
create mode 100644 tests/Feature/Checkout/CheckoutPaymentApiTest.php
create mode 100644 tests/Feature/Checkout/OrderStatusApiTest.php
create mode 100644 tests/Feature/Orders/FulfillmentTest.php
create mode 100644 tests/Feature/Orders/OrderCreationTest.php
create mode 100644 tests/Feature/Orders/RefundTest.php
create mode 100644 tests/Feature/Payments/BankTransferConfirmationTest.php
create mode 100644 tests/Feature/Payments/MockPaymentProviderTest.php
create mode 100644 tests/Feature/Payments/PaymentServiceTest.php
diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php
new file mode 100644
index 00000000..62cfa481
--- /dev/null
+++ b/app/Contracts/PaymentProvider.php
@@ -0,0 +1,28 @@
+ $details
+ */
+ public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult;
+
+ /**
+ * Refund (part of) a captured payment.
+ */
+ public function refund(Payment $payment, int $amount): RefundResult;
+}
diff --git a/app/Events/CheckoutCompleted.php b/app/Events/CheckoutCompleted.php
new file mode 100644
index 00000000..fa0daf8c
--- /dev/null
+++ b/app/Events/CheckoutCompleted.php
@@ -0,0 +1,14 @@
+refresh());
}
+ /**
+ * POST /api/storefront/v1/checkouts/{checkoutId}/pay (spec 02 §2.3):
+ * charge the selected payment method via the Mock PSP and create the
+ * order. Idempotent — a completed checkout returns its existing order.
+ */
+ public function pay(Request $request, int $checkoutId): JsonResponse
+ {
+ $validated = $request->validate([
+ 'payment_method' => ['required', 'string', 'in:credit_card,paypal,bank_transfer'],
+ 'card_number' => ['required_if:payment_method,credit_card', 'string', 'max:25'],
+ 'card_expiry' => ['required_if:payment_method,credit_card', 'string', 'max:7'],
+ 'card_cvc' => ['required_if:payment_method,credit_card', 'string', 'max:4'],
+ 'card_holder' => ['required_if:payment_method,credit_card', 'string', 'max:255'],
+ ]);
+
+ $checkout = Checkout::findOrFail($checkoutId);
+ $this->guardNotExpired($checkout);
+
+ if ($checkout->status === CheckoutStatus::Completed) {
+ $order = Order::query()->where('checkout_id', $checkout->id)->firstOrFail();
+
+ return $this->paymentResponse($checkout, $order);
+ }
+
+ abort_if($checkout->status !== CheckoutStatus::PaymentSelected, 409, 'The checkout is not in a valid state for payment.');
+
+ if ($checkout->payment_method->value !== $validated['payment_method']) {
+ throw ValidationException::withMessages([
+ 'payment_method' => ['The payment method does not match the method selected for this checkout.'],
+ ]);
+ }
+
+ try {
+ $order = $this->checkoutService->completeCheckout($checkout, $validated);
+ } catch (PaymentFailedException $exception) {
+ return response()->json([
+ 'message' => $exception->getMessage(),
+ 'error_code' => $exception->errorCode,
+ ], 422);
+ } catch (InsufficientInventoryException $exception) {
+ return response()->json([
+ 'message' => $exception->getMessage(),
+ 'error_code' => 'insufficient_inventory',
+ ], 422);
+ }
+
+ return $this->paymentResponse($checkout->refresh(), $order);
+ }
+
/**
* DELETE /api/storefront/v1/checkouts/{checkoutId}/discount.
*/
@@ -139,6 +195,39 @@ public function removeDiscount(int $checkoutId): CheckoutResource
return new CheckoutResource($checkout->refresh());
}
+ /**
+ * Build the /pay success payload (spec 02 §2.3), including bank transfer
+ * instructions for deferred payments.
+ */
+ private function paymentResponse(Checkout $checkout, Order $order): JsonResponse
+ {
+ $payload = [
+ 'checkout_id' => $checkout->id,
+ 'status' => CheckoutStatus::Completed->value,
+ 'order' => [
+ 'id' => $order->id,
+ 'order_number' => $order->order_number,
+ 'status' => $order->status->value,
+ 'financial_status' => $order->financial_status->value,
+ 'payment_method' => $order->payment_method->value,
+ 'total_amount' => $order->total_amount,
+ 'currency' => $order->currency,
+ ],
+ ];
+
+ if ($order->payment_method === PaymentMethod::BankTransfer) {
+ $payload['bank_transfer_instructions'] = [
+ 'bank_name' => 'Mock Bank AG',
+ 'iban' => 'DE89 3704 0044 0532 0130 00',
+ 'bic' => 'COBADEFFXXX',
+ 'reference' => $order->order_number,
+ 'amount_formatted' => Money::format($order->total_amount, $order->currency),
+ ];
+ }
+
+ return response()->json($payload);
+ }
+
/**
* Abort with 410 when the checkout has expired.
*/
diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php
new file mode 100644
index 00000000..10bfc90f
--- /dev/null
+++ b/app/Http/Controllers/Api/Storefront/OrderController.php
@@ -0,0 +1,72 @@
+where('order_number', $orderNumber)
+ ->firstOrFail();
+
+ abort_unless(OrderToken::validate($order, $request->query('token')), 401, 'Invalid or missing order token.');
+
+ $address = $order->shipping_address_json ?? [];
+
+ return response()->json([
+ 'order_number' => $order->order_number,
+ 'status' => $order->status->value,
+ 'financial_status' => $order->financial_status->value,
+ 'fulfillment_status' => $order->fulfillment_status->value,
+ 'email' => $order->email,
+ 'currency' => $order->currency,
+ 'placed_at' => $order->placed_at?->toIso8601ZuluString(),
+ 'lines' => $order->lines->map(fn ($line): array => [
+ 'title_snapshot' => $line->title_snapshot,
+ 'variant_title' => $line->variant?->title(),
+ 'sku_snapshot' => $line->sku_snapshot,
+ 'quantity' => $line->quantity,
+ 'unit_price_amount' => $line->unit_price_amount,
+ 'total_amount' => $line->total_amount,
+ ])->all(),
+ 'totals' => [
+ 'subtotal_amount' => $order->subtotal_amount,
+ 'discount_amount' => $order->discount_amount,
+ 'shipping_amount' => $order->shipping_amount,
+ 'tax_amount' => $order->tax_amount,
+ 'total_amount' => $order->total_amount,
+ ],
+ 'shipping_address' => [
+ 'first_name' => $address['first_name'] ?? null,
+ 'last_name' => $address['last_name'] ?? null,
+ 'address1' => $address['address1'] ?? null,
+ 'city' => $address['city'] ?? null,
+ 'country' => $address['country_code'] ?? $address['country'] ?? null,
+ 'postal_code' => $address['postal_code'] ?? null,
+ ],
+ 'fulfillments' => $order->fulfillments->map(fn ($fulfillment): array => [
+ 'id' => $fulfillment->id,
+ 'status' => $fulfillment->status->value,
+ 'tracking_company' => $fulfillment->tracking_company,
+ 'tracking_number' => $fulfillment->tracking_number,
+ 'tracking_url' => $fulfillment->tracking_url,
+ 'shipped_at' => $fulfillment->shipped_at?->toIso8601ZuluString(),
+ ])->all(),
+ ]);
+ }
+}
diff --git a/app/Jobs/CancelUnpaidBankTransferOrders.php b/app/Jobs/CancelUnpaidBankTransferOrders.php
new file mode 100644
index 00000000..a19068c5
--- /dev/null
+++ b/app/Jobs/CancelUnpaidBankTransferOrders.php
@@ -0,0 +1,48 @@
+where('payment_method', PaymentMethod::BankTransfer->value)
+ ->where('financial_status', FinancialStatus::Pending->value);
+
+ foreach ($candidates->pluck('store_id')->unique() as $storeId) {
+ $store = Store::find($storeId);
+ $days = (int) ($store?->settings?->settings_json['bank_transfer_cancel_days'] ?? 7);
+
+ Order::query()
+ ->where('store_id', $storeId)
+ ->where('payment_method', PaymentMethod::BankTransfer->value)
+ ->where('financial_status', FinancialStatus::Pending->value)
+ ->where('placed_at', '<', now()->subDays($days))
+ ->chunkById(100, function ($stale) use ($orders): void {
+ foreach ($stale as $order) {
+ $orders->cancel($order, 'bank_transfer_unpaid_timeout');
+ }
+ });
+ }
+ }
+}
diff --git a/app/Listeners/ReleaseOrderInventory.php b/app/Listeners/ReleaseOrderInventory.php
new file mode 100644
index 00000000..f9f2b75b
--- /dev/null
+++ b/app/Listeners/ReleaseOrderInventory.php
@@ -0,0 +1,42 @@
+order;
+
+ if ($order->financial_status !== FinancialStatus::Pending) {
+ return;
+ }
+
+ $order->loadMissing('lines.variant.inventoryItem');
+
+ foreach ($order->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->release($item, $line->quantity);
+ }
+ }
+ }
+}
diff --git a/app/Listeners/WriteAuditLog.php b/app/Listeners/WriteAuditLog.php
new file mode 100644
index 00000000..cd397756
--- /dev/null
+++ b/app/Listeners/WriteAuditLog.php
@@ -0,0 +1,66 @@
+orderOf($event);
+
+ Log::channel('audit')->info('order.'.$this->eventName($event), array_filter([
+ 'event' => $event::class,
+ 'store_id' => $order?->store_id,
+ 'order_id' => $order?->id,
+ 'order_number' => $order?->order_number,
+ 'financial_status' => $order?->financial_status?->value,
+ 'status' => $order?->status?->value,
+ 'fulfillment_id' => $event->fulfillment->id ?? null,
+ 'refund_id' => $event->refund->id ?? null,
+ 'reason' => $event->reason ?? null,
+ ], fn ($value): bool => $value !== null));
+ }
+
+ /**
+ * Resolve the order the event relates to.
+ */
+ private function orderOf(object $event): ?Order
+ {
+ if (isset($event->order) && $event->order instanceof Order) {
+ return $event->order;
+ }
+
+ if (isset($event->fulfillment)) {
+ return $event->fulfillment->order;
+ }
+
+ return null;
+ }
+
+ /**
+ * Map the event class to a short audit action name.
+ */
+ private function eventName(object $event): string
+ {
+ return match (class_basename($event)) {
+ 'OrderCreated' => 'created',
+ 'OrderPaid' => 'paid',
+ 'OrderCancelled' => 'cancelled',
+ 'OrderRefunded' => 'refunded',
+ 'FulfillmentShipped' => 'fulfillment_shipped',
+ default => strtolower(class_basename($event)),
+ };
+ }
+}
diff --git a/app/Livewire/Storefront/Checkout/Confirmation.php b/app/Livewire/Storefront/Checkout/Confirmation.php
new file mode 100644
index 00000000..92b69600
--- /dev/null
+++ b/app/Livewire/Storefront/Checkout/Confirmation.php
@@ -0,0 +1,72 @@
+where('checkout_id', $checkout->id)
+ ->first();
+
+ abort_if($order === null, 404);
+
+ $this->checkout = $checkout;
+ $this->order = $order;
+ }
+
+ /**
+ * Tokenized URL of the guest order status API endpoint.
+ */
+ public function orderStatusUrl(): string
+ {
+ return '/api/storefront/v1/orders/'.urlencode($this->order->order_number).'?token='.OrderToken::for($this->order);
+ }
+
+ /**
+ * Render the confirmation page.
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.checkout.confirmation', [
+ 'paymentLast4' => $this->paymentLast4(),
+ 'isBankTransfer' => $this->order->payment_method === PaymentMethod::BankTransfer,
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('Order '.$this->order->order_number.' - '.app('current_store')->name);
+ }
+
+ /**
+ * Last four card digits from the sanitized provider payload, if any.
+ */
+ private function paymentLast4(): ?string
+ {
+ return $this->order->payments->first()?->raw_json_encrypted['card_last4'] ?? null;
+ }
+}
diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php
index eb03990e..563e5b26 100644
--- a/app/Livewire/Storefront/Checkout/Show.php
+++ b/app/Livewire/Storefront/Checkout/Show.php
@@ -3,6 +3,8 @@
namespace App\Livewire\Storefront\Checkout;
use App\Enums\CheckoutStatus;
+use App\Exceptions\InsufficientInventoryException;
+use App\Exceptions\PaymentFailedException;
use App\Models\Checkout;
use App\Services\CartService;
use App\Services\CheckoutService;
@@ -18,8 +20,7 @@
* Step 1 (contact + shipping address) runs under the "new" route parameter:
* submitting it creates the checkout from the session cart and sets the
* address, then redirects to /checkout/{id} for step 2 (shipping method)
- * and step 3 (payment method). The actual payment is Phase 5; the pay
- * button renders disabled with a placeholder note.
+ * and step 3 (payment method + pay via the Mock PSP).
*/
class Show extends Component
{
@@ -57,6 +58,16 @@ class Show extends Component
public bool $paymentSelected = false;
+ public string $cardNumber = '';
+
+ public string $cardExpiry = '';
+
+ public string $cardCvc = '';
+
+ public string $cardHolder = '';
+
+ public ?string $paymentError = null;
+
public string $discountCode = '';
public ?string $discountError = null;
@@ -194,8 +205,7 @@ public function continueWithoutShipping(): void
}
/**
- * Step 3: record the payment method (reserves inventory). The actual
- * payment processing ships in Phase 5.
+ * Step 3: record the payment method (reserves inventory).
*/
public function selectPayment(): void
{
@@ -210,6 +220,52 @@ public function selectPayment(): void
$this->paymentSelected = true;
}
+ /**
+ * Step 3 submit: charge the selected payment method and create the order
+ * (spec 04 §8.2). On decline the customer stays on the payment step and
+ * sees the error; on success they are redirected to the confirmation.
+ */
+ public function pay(): void
+ {
+ $this->paymentError = null;
+
+ $checkout = Checkout::findOrFail($this->checkoutDbId);
+ $method = $checkout->payment_method?->value ?? $this->paymentMethod;
+
+ $rules = [];
+
+ if ($method === 'credit_card') {
+ $rules = [
+ 'cardNumber' => ['required', 'string', 'max:25'],
+ 'cardExpiry' => ['required', 'string', 'max:7'],
+ 'cardCvc' => ['required', 'string', 'max:4'],
+ 'cardHolder' => ['required', 'string', 'max:255'],
+ ];
+ }
+
+ $this->validate($rules);
+
+ try {
+ app(CheckoutService::class)->completeCheckout($checkout, [
+ 'payment_method' => $method,
+ 'card_number' => $this->cardNumber,
+ 'card_expiry' => $this->cardExpiry,
+ 'card_cvc' => $this->cardCvc,
+ 'card_holder' => $this->cardHolder,
+ ]);
+ } catch (PaymentFailedException $exception) {
+ $this->paymentError = 'Payment declined: '.$exception->getMessage();
+
+ return;
+ } catch (InsufficientInventoryException) {
+ $this->paymentError = 'Some items in your order are no longer available.';
+
+ return;
+ }
+
+ $this->redirectRoute('storefront.checkout.confirmation', ['checkoutId' => $checkout->id]);
+ }
+
/**
* Apply a discount code to the checkout.
*/
diff --git a/app/Models/Customer.php b/app/Models/Customer.php
index e16dd3f0..94b9306c 100644
--- a/app/Models/Customer.php
+++ b/app/Models/Customer.php
@@ -4,6 +4,7 @@
use App\Models\Concerns\BelongsToStore;
use Illuminate\Database\Eloquent\Factories\HasFactory;
+use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
@@ -54,4 +55,34 @@ public function getAuthPassword(): string
{
return $this->password_hash ?? '';
}
+
+ /**
+ * Get the saved addresses for the customer.
+ *
+ * @return HasMany
+ */
+ public function addresses(): HasMany
+ {
+ return $this->hasMany(CustomerAddress::class);
+ }
+
+ /**
+ * Get the orders placed by the customer.
+ *
+ * @return HasMany
+ */
+ public function orders(): HasMany
+ {
+ return $this->hasMany(Order::class);
+ }
+
+ /**
+ * Get the carts owned by the customer.
+ *
+ * @return HasMany
+ */
+ public function carts(): HasMany
+ {
+ return $this->hasMany(Cart::class);
+ }
}
diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php
new file mode 100644
index 00000000..6790776f
--- /dev/null
+++ b/app/Models/CustomerAddress.php
@@ -0,0 +1,55 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'customer_id',
+ 'label',
+ 'address_json',
+ 'is_default',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'address_json' => 'array',
+ 'is_default' => 'boolean',
+ ];
+ }
+
+ /**
+ * Get the customer that owns the address.
+ *
+ * @return BelongsTo
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Customer::class);
+ }
+}
diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php
new file mode 100644
index 00000000..cd09c185
--- /dev/null
+++ b/app/Models/Fulfillment.php
@@ -0,0 +1,67 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table only has created_at, no updated_at.
+ */
+ public const UPDATED_AT = null;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'order_id',
+ 'status',
+ 'tracking_company',
+ 'tracking_number',
+ 'tracking_url',
+ 'shipped_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => FulfillmentShipmentStatus::class,
+ 'shipped_at' => 'datetime',
+ ];
+ }
+
+ /**
+ * Get the order the fulfillment belongs to.
+ *
+ * @return BelongsTo
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class);
+ }
+
+ /**
+ * Get the lines included in the fulfillment.
+ *
+ * @return HasMany
+ */
+ public function lines(): HasMany
+ {
+ return $this->hasMany(FulfillmentLine::class);
+ }
+}
diff --git a/app/Models/FulfillmentLine.php b/app/Models/FulfillmentLine.php
new file mode 100644
index 00000000..b06ac79c
--- /dev/null
+++ b/app/Models/FulfillmentLine.php
@@ -0,0 +1,63 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'fulfillment_id',
+ 'order_line_id',
+ 'quantity',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'quantity' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the fulfillment the line belongs to.
+ *
+ * @return BelongsTo
+ */
+ public function fulfillment(): BelongsTo
+ {
+ return $this->belongsTo(Fulfillment::class);
+ }
+
+ /**
+ * Get the order line being fulfilled.
+ *
+ * @return BelongsTo
+ */
+ public function orderLine(): BelongsTo
+ {
+ return $this->belongsTo(OrderLine::class);
+ }
+}
diff --git a/app/Models/Order.php b/app/Models/Order.php
new file mode 100644
index 00000000..cf7288e8
--- /dev/null
+++ b/app/Models/Order.php
@@ -0,0 +1,172 @@
+ */
+ use BelongsToStore, HasFactory;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'store_id',
+ 'checkout_id',
+ 'customer_id',
+ 'order_number',
+ 'payment_method',
+ 'status',
+ 'financial_status',
+ 'fulfillment_status',
+ 'currency',
+ 'subtotal_amount',
+ 'discount_amount',
+ 'shipping_amount',
+ 'tax_amount',
+ 'total_amount',
+ 'email',
+ 'billing_address_json',
+ 'shipping_address_json',
+ 'placed_at',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => OrderStatus::class,
+ 'financial_status' => FinancialStatus::class,
+ 'fulfillment_status' => FulfillmentOrderStatus::class,
+ 'payment_method' => PaymentMethod::class,
+ 'billing_address_json' => 'array',
+ 'shipping_address_json' => 'array',
+ 'placed_at' => 'datetime',
+ 'subtotal_amount' => 'integer',
+ 'discount_amount' => 'integer',
+ 'shipping_amount' => 'integer',
+ 'tax_amount' => 'integer',
+ 'total_amount' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the checkout the order was created from.
+ *
+ * @return BelongsTo
+ */
+ public function checkout(): BelongsTo
+ {
+ return $this->belongsTo(Checkout::class);
+ }
+
+ /**
+ * Get the customer that placed the order (null for unlinked guests).
+ *
+ * @return BelongsTo
+ */
+ public function customer(): BelongsTo
+ {
+ return $this->belongsTo(Customer::class);
+ }
+
+ /**
+ * Get the lines in the order.
+ *
+ * @return HasMany
+ */
+ public function lines(): HasMany
+ {
+ return $this->hasMany(OrderLine::class);
+ }
+
+ /**
+ * Get the payments recorded for the order.
+ *
+ * @return HasMany
+ */
+ public function payments(): HasMany
+ {
+ return $this->hasMany(Payment::class);
+ }
+
+ /**
+ * Get the refunds recorded for the order.
+ *
+ * @return HasMany
+ */
+ public function refunds(): HasMany
+ {
+ return $this->hasMany(Refund::class);
+ }
+
+ /**
+ * Get the fulfillments for the order.
+ *
+ * @return HasMany
+ */
+ public function fulfillments(): HasMany
+ {
+ return $this->hasMany(Fulfillment::class);
+ }
+
+ /**
+ * Whether payment has been captured for the order.
+ */
+ public function isPaid(): bool
+ {
+ return $this->financial_status === FinancialStatus::Paid;
+ }
+
+ /**
+ * Whether every line is a digital item (no shipping required). A line
+ * whose variant was deleted counts as physical (fallback: shipping
+ * required), so nothing is silently auto-fulfilled.
+ */
+ public function isDigital(): bool
+ {
+ $this->loadMissing('lines.variant');
+
+ return $this->lines->isNotEmpty() && $this->lines->every(
+ fn (OrderLine $line): bool => $line->variant !== null && ! $line->variant->requires_shipping
+ );
+ }
+
+ /**
+ * Amount that can still be refunded: total minus non-failed refunds.
+ */
+ public function refundableAmount(): int
+ {
+ $refunded = $this->refunds()
+ ->whereIn('status', [RefundStatus::Pending->value, RefundStatus::Processed->value])
+ ->sum('amount');
+
+ return max(0, $this->total_amount - (int) $refunded);
+ }
+
+ /**
+ * Formatted grand total for display, e.g. "65.45 EUR".
+ */
+ public function formattedTotal(): string
+ {
+ return Money::format($this->total_amount, $this->currency);
+ }
+}
diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php
new file mode 100644
index 00000000..69fde069
--- /dev/null
+++ b/app/Models/OrderLine.php
@@ -0,0 +1,96 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table has no timestamp columns.
+ *
+ * @var bool
+ */
+ public $timestamps = false;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'order_id',
+ 'product_id',
+ 'variant_id',
+ 'title_snapshot',
+ 'sku_snapshot',
+ 'quantity',
+ 'unit_price_amount',
+ 'total_amount',
+ 'tax_lines_json',
+ 'discount_allocations_json',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'tax_lines_json' => 'array',
+ 'discount_allocations_json' => 'array',
+ 'quantity' => 'integer',
+ 'unit_price_amount' => 'integer',
+ 'total_amount' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the order that owns the line.
+ *
+ * @return BelongsTo
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class);
+ }
+
+ /**
+ * Get the product the line references (null after product deletion).
+ *
+ * @return BelongsTo
+ */
+ public function product(): BelongsTo
+ {
+ return $this->belongsTo(Product::class);
+ }
+
+ /**
+ * Get the variant the line references (null after variant deletion).
+ *
+ * @return BelongsTo
+ */
+ public function variant(): BelongsTo
+ {
+ return $this->belongsTo(ProductVariant::class, 'variant_id');
+ }
+
+ /**
+ * Units of this line not yet covered by any fulfillment.
+ */
+ public function unfulfilledQuantity(): int
+ {
+ $fulfilled = FulfillmentLine::query()
+ ->where('order_line_id', $this->id)
+ ->sum('quantity');
+
+ return max(0, $this->quantity - (int) $fulfilled);
+ }
+}
diff --git a/app/Models/Payment.php b/app/Models/Payment.php
new file mode 100644
index 00000000..af3a76e0
--- /dev/null
+++ b/app/Models/Payment.php
@@ -0,0 +1,72 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table only has created_at, no updated_at.
+ */
+ public const UPDATED_AT = null;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'order_id',
+ 'provider',
+ 'method',
+ 'provider_payment_id',
+ 'status',
+ 'amount',
+ 'currency',
+ 'raw_json_encrypted',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'method' => PaymentMethod::class,
+ 'status' => PaymentStatus::class,
+ 'amount' => 'integer',
+ 'raw_json_encrypted' => 'encrypted:array',
+ ];
+ }
+
+ /**
+ * Get the order the payment belongs to.
+ *
+ * @return BelongsTo
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class);
+ }
+
+ /**
+ * Get the refunds issued against this payment.
+ *
+ * @return HasMany
+ */
+ public function refunds(): HasMany
+ {
+ return $this->hasMany(Refund::class);
+ }
+}
diff --git a/app/Models/Refund.php b/app/Models/Refund.php
new file mode 100644
index 00000000..db9e308f
--- /dev/null
+++ b/app/Models/Refund.php
@@ -0,0 +1,66 @@
+ */
+ use HasFactory;
+
+ /**
+ * The table only has created_at, no updated_at.
+ */
+ public const UPDATED_AT = null;
+
+ /**
+ * The attributes that are mass assignable.
+ *
+ * @var list
+ */
+ protected $fillable = [
+ 'order_id',
+ 'payment_id',
+ 'amount',
+ 'reason',
+ 'status',
+ 'provider_refund_id',
+ ];
+
+ /**
+ * Get the attributes that should be cast.
+ *
+ * @return array
+ */
+ protected function casts(): array
+ {
+ return [
+ 'status' => RefundStatus::class,
+ 'amount' => 'integer',
+ ];
+ }
+
+ /**
+ * Get the order the refund belongs to.
+ *
+ * @return BelongsTo
+ */
+ public function order(): BelongsTo
+ {
+ return $this->belongsTo(Order::class);
+ }
+
+ /**
+ * Get the payment the refund was issued against.
+ *
+ * @return BelongsTo
+ */
+ public function payment(): BelongsTo
+ {
+ return $this->belongsTo(Payment::class);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index 6fb8481f..b8451a8a 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -3,8 +3,16 @@
namespace App\Providers;
use App\Auth\CustomerUserProvider;
+use App\Contracts\PaymentProvider;
use App\Enums\StoreUserRole;
+use App\Events\FulfillmentShipped;
+use App\Events\OrderCancelled;
+use App\Events\OrderCreated;
+use App\Events\OrderPaid;
+use App\Events\OrderRefunded;
+use App\Listeners\WriteAuditLog;
use App\Models\User;
+use App\Services\Payments\MockPaymentProvider;
use App\Services\ThemeSettingsService;
use Carbon\CarbonImmutable;
use Illuminate\Cache\RateLimiting\Limit;
@@ -14,6 +22,7 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
@@ -27,6 +36,9 @@ class AppServiceProvider extends ServiceProvider
public function register(): void
{
$this->app->singleton(ThemeSettingsService::class);
+
+ // Mock PSP: in-process payment provider (spec 05 §10).
+ $this->app->bind(PaymentProvider::class, MockPaymentProvider::class);
}
/**
@@ -38,6 +50,7 @@ public function boot(): void
$this->configureAuth();
$this->configureGates();
$this->configureRateLimiting();
+ $this->configureAuditLog();
// Anonymous storefront components:
Blade::anonymousComponentPath(resource_path('views/storefront/components'), 'storefront');
@@ -111,6 +124,21 @@ protected function gateAllows(User $user, array $roles): bool
return $role !== null && in_array($role, $roles, true);
}
+ /**
+ * Register the audit log listener for order lifecycle events
+ * (spec 05 §17, spec 06 §4.6).
+ */
+ protected function configureAuditLog(): void
+ {
+ Event::listen([
+ OrderCreated::class,
+ OrderPaid::class,
+ OrderCancelled::class,
+ OrderRefunded::class,
+ FulfillmentShipped::class,
+ ], WriteAuditLog::class);
+ }
+
/**
* Register the application's rate limiters (spec 06 §4.2).
*/
diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php
index 4d0b0300..f6a529a9 100644
--- a/app/Services/CheckoutService.php
+++ b/app/Services/CheckoutService.php
@@ -8,11 +8,14 @@
use App\Events\CheckoutAddressed;
use App\Events\CheckoutExpired;
use App\Events\CheckoutShippingSelected;
+use App\Exceptions\InsufficientInventoryException;
use App\Exceptions\InvalidCheckoutTransitionException;
+use App\Exceptions\PaymentFailedException;
use App\Models\Cart;
use App\Models\Checkout;
use App\Models\Customer;
use App\Models\Discount;
+use App\Models\Order;
use App\Models\ShippingRate;
use App\Models\TaxSettings;
use App\ValueObjects\Address;
@@ -28,9 +31,6 @@
* started -> addressed -> shipping_selected -> payment_selected -> completed.
* Any active state can transition to expired. Pricing is recalculated on
* every significant state change and snapshotted to checkouts.totals_json.
- *
- * The final `payment_selected -> completed` transition (completeCheckout)
- * is implemented in Phase 5 together with payments and orders.
*/
class CheckoutService
{
@@ -40,6 +40,8 @@ public function __construct(
private ShippingCalculator $shipping,
private TaxCalculator $taxCalculator,
private InventoryService $inventory,
+ private PaymentService $payments,
+ private OrderService $orders,
) {}
/**
@@ -202,6 +204,67 @@ public function selectPaymentMethod(Checkout $checkout, PaymentMethod|string $me
return $checkout->refresh();
}
+ /**
+ * Transition payment_selected -> completed (spec 05 §6.2). Charges the
+ * payment via the Mock PSP and creates the order. IDEMPOTENT: repeated
+ * calls for the same checkout return the already-created order.
+ *
+ * On payment failure the reserved inventory is released (in its own
+ * transaction so the release is not rolled back by the thrown
+ * exception), the checkout stays payment_selected, and a
+ * PaymentFailedException is thrown. The reservation is refreshed
+ * (released + re-reserved) before every charge attempt so retries after
+ * a decline re-establish it and re-validate availability.
+ *
+ * @param array $paymentDetails
+ *
+ * @throws InvalidCheckoutTransitionException|PaymentFailedException|InsufficientInventoryException
+ */
+ public function completeCheckout(Checkout $checkout, array $paymentDetails = []): Order
+ {
+ $this->assertStatus($checkout, [CheckoutStatus::PaymentSelected, CheckoutStatus::Completed], 'completeCheckout');
+
+ $existing = Order::query()->where('checkout_id', $checkout->id)->first();
+
+ if ($existing !== null) {
+ return $existing;
+ }
+
+ $checkout->loadMissing('cart.lines.variant.inventoryItem');
+
+ DB::transaction(function () use ($checkout): void {
+ foreach ($checkout->cart->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->release($item, $line->quantity);
+ $this->inventory->reserve($item, $line->quantity);
+ }
+ }
+ });
+
+ $result = $this->payments->charge($checkout, $checkout->payment_method, $paymentDetails);
+
+ if (! $result->success) {
+ DB::transaction(function () use ($checkout): void {
+ foreach ($checkout->cart->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->release($item, $line->quantity);
+ }
+ }
+ });
+
+ throw new PaymentFailedException(
+ $result->errorCode ?? 'payment_failed',
+ $result->errorMessage ?? 'The payment failed.',
+ );
+ }
+
+ return $this->orders->createFromCheckout($checkout, $result, $paymentDetails);
+ }
+
/**
* Validate and apply a discount code, then recalculate totals.
*/
diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php
new file mode 100644
index 00000000..be81cf56
--- /dev/null
+++ b/app/Services/FulfillmentService.php
@@ -0,0 +1,211 @@
+ quantity).
+ *
+ * @param array $lines
+ * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null}|null $tracking
+ *
+ * @throws FulfillmentGuardException|ValidationException
+ */
+ public function create(Order $order, array $lines, ?array $tracking = null): Fulfillment
+ {
+ if (! in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded], true)) {
+ throw FulfillmentGuardException::forFinancialStatus($order->financial_status->value);
+ }
+
+ return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment {
+ $order->loadMissing('lines');
+ $fulfilledSoFar = $this->fulfilledQuantities($order);
+ $orderLines = $order->lines->keyBy('id');
+
+ $errors = [];
+
+ foreach ($lines as $orderLineId => $quantity) {
+ $orderLine = $orderLines->get((int) $orderLineId);
+ $quantity = (int) $quantity;
+
+ if ($orderLine === null) {
+ $errors["lines.{$orderLineId}"] = ['The order line does not belong to this order.'];
+
+ continue;
+ }
+
+ $unfulfilled = $orderLine->quantity - ($fulfilledSoFar[$orderLine->id] ?? 0);
+
+ if ($quantity < 1 || $quantity > $unfulfilled) {
+ $errors["lines.{$orderLineId}"] = ["Cannot fulfill {$quantity} units; only {$unfulfilled} unfulfilled."];
+ }
+ }
+
+ if ($errors !== []) {
+ throw ValidationException::withMessages($errors);
+ }
+
+ $fulfillment = $order->fulfillments()->create([
+ 'status' => FulfillmentShipmentStatus::Pending,
+ 'tracking_company' => $tracking['tracking_company'] ?? null,
+ 'tracking_number' => $tracking['tracking_number'] ?? null,
+ 'tracking_url' => $tracking['tracking_url'] ?? null,
+ ]);
+
+ foreach ($lines as $orderLineId => $quantity) {
+ $fulfillment->lines()->create([
+ 'order_line_id' => (int) $orderLineId,
+ 'quantity' => (int) $quantity,
+ ]);
+ }
+
+ $this->recomputeOrderStatus($order->refresh());
+
+ FulfillmentCreated::dispatch($fulfillment);
+
+ return $fulfillment;
+ });
+ }
+
+ /**
+ * Auto-create a delivered fulfillment covering all lines of an
+ * all-digital order (spec 05 §11.7). Skips pending/shipped and sets
+ * shipped_at immediately. No-op for mixed or physical orders.
+ */
+ public function autoFulfillDigital(Order $order): ?Fulfillment
+ {
+ if (! $order->isDigital() || $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled) {
+ return null;
+ }
+
+ $order->loadMissing('lines');
+
+ $fulfillment = $order->fulfillments()->create([
+ 'status' => FulfillmentShipmentStatus::Delivered,
+ 'shipped_at' => now(),
+ ]);
+
+ foreach ($order->lines as $line) {
+ $fulfillment->lines()->create([
+ 'order_line_id' => $line->id,
+ 'quantity' => $line->quantity,
+ ]);
+ }
+
+ $order->forceFill([
+ 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled,
+ 'status' => OrderStatus::Fulfilled,
+ ])->save();
+
+ FulfillmentCreated::dispatch($fulfillment);
+ OrderFulfilled::dispatch($order);
+
+ return $fulfillment;
+ }
+
+ /**
+ * Transition pending -> shipped: set tracking data and shipped_at.
+ *
+ * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null}|null $tracking
+ */
+ public function markAsShipped(Fulfillment $fulfillment, ?array $tracking = null): void
+ {
+ $fulfillment->forceFill([
+ 'status' => FulfillmentShipmentStatus::Shipped,
+ 'shipped_at' => $fulfillment->shipped_at ?? now(),
+ 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company,
+ 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number,
+ 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url,
+ ])->save();
+
+ FulfillmentShipped::dispatch($fulfillment);
+ }
+
+ /**
+ * Transition shipped -> delivered.
+ */
+ public function markAsDelivered(Fulfillment $fulfillment): void
+ {
+ $fulfillment->forceFill(['status' => FulfillmentShipmentStatus::Delivered])->save();
+
+ FulfillmentDelivered::dispatch($fulfillment);
+ }
+
+ /**
+ * Recompute the order's fulfillment_status (and overall status when fully
+ * fulfilled) from all fulfillment lines.
+ */
+ private function recomputeOrderStatus(Order $order): void
+ {
+ $order->loadMissing('lines');
+ $fulfilled = $this->fulfilledQuantities($order);
+
+ $allFulfilled = true;
+ $anyFulfilled = false;
+
+ foreach ($order->lines as $line) {
+ $quantity = (int) ($fulfilled[$line->id] ?? 0);
+
+ if ($quantity > 0) {
+ $anyFulfilled = true;
+ }
+
+ if ($quantity < $line->quantity) {
+ $allFulfilled = false;
+ }
+ }
+
+ if ($allFulfilled && $order->lines->isNotEmpty()) {
+ $wasFulfilled = $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled;
+
+ $order->forceFill([
+ 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled,
+ 'status' => OrderStatus::Fulfilled,
+ ])->save();
+
+ if (! $wasFulfilled) {
+ OrderFulfilled::dispatch($order);
+ }
+ } elseif ($anyFulfilled) {
+ $order->forceFill(['fulfillment_status' => FulfillmentOrderStatus::Partial])->save();
+ }
+ }
+
+ /**
+ * Fulfilled quantity per order line id, across all fulfillments.
+ *
+ * @return array
+ */
+ private function fulfilledQuantities(Order $order): array
+ {
+ return FulfillmentLine::query()
+ ->whereIn('fulfillment_id', $order->fulfillments()->pluck('id'))
+ ->selectRaw('order_line_id, SUM(quantity) as total')
+ ->groupBy('order_line_id')
+ ->pluck('total', 'order_line_id')
+ ->map(fn ($total): int => (int) $total)
+ ->all();
+ }
+}
diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php
index a658deb1..66311bfe 100644
--- a/app/Services/InventoryService.php
+++ b/app/Services/InventoryService.php
@@ -42,12 +42,16 @@ public function reserve(InventoryItem $item, int $quantity): void
}
/**
- * Release a reservation (checkout expired/abandoned): reserved -= quantity.
+ * Release a reservation (checkout expired/abandoned, payment declined):
+ * reserved -= quantity. Floored at zero so double-release paths (e.g.
+ * release on payment failure followed by checkout expiry) can never
+ * drive reserved stock negative.
*/
public function release(InventoryItem $item, int $quantity): void
{
DB::transaction(function () use ($item, $quantity): void {
- $this->lockAndRefresh($item)->decrement('quantity_reserved', $quantity);
+ $locked = $this->lockAndRefresh($item);
+ $locked->decrement('quantity_reserved', min($quantity, $locked->quantity_reserved));
});
}
diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php
new file mode 100644
index 00000000..1102d9d6
--- /dev/null
+++ b/app/Services/OrderService.php
@@ -0,0 +1,320 @@
+ $paymentDetails
+ */
+ public function createFromCheckout(Checkout $checkout, PaymentResult $paymentResult, array $paymentDetails = []): Order
+ {
+ return DB::transaction(function () use ($checkout, $paymentResult, $paymentDetails): Order {
+ $existing = Order::query()->where('checkout_id', $checkout->id)->first();
+
+ if ($existing !== null) {
+ return $existing;
+ }
+
+ $checkout->loadMissing([
+ 'cart.lines.variant.product',
+ 'cart.lines.variant.inventoryItem',
+ 'cart.lines.variant.optionValues.option',
+ 'store.settings',
+ ]);
+
+ $cart = $checkout->cart;
+ $method = $checkout->payment_method;
+ $instantCapture = in_array($method, [PaymentMethod::CreditCard, PaymentMethod::Paypal], true);
+ $totals = $checkout->totals_json ?? [];
+
+ $order = Order::create([
+ 'store_id' => $checkout->store_id,
+ 'checkout_id' => $checkout->id,
+ 'customer_id' => $checkout->customer_id,
+ 'order_number' => $this->generateOrderNumber($checkout->store),
+ 'payment_method' => $method,
+ 'status' => $instantCapture ? OrderStatus::Paid : OrderStatus::Pending,
+ 'financial_status' => $instantCapture ? FinancialStatus::Paid : FinancialStatus::Pending,
+ 'fulfillment_status' => FulfillmentOrderStatus::Unfulfilled,
+ 'currency' => $cart->currency,
+ 'subtotal_amount' => (int) ($totals['subtotal'] ?? 0),
+ 'discount_amount' => (int) ($totals['discount'] ?? 0),
+ 'shipping_amount' => (int) ($totals['shipping'] ?? 0),
+ 'tax_amount' => (int) ($totals['tax'] ?? 0),
+ 'total_amount' => (int) ($totals['total'] ?? 0),
+ 'email' => $checkout->email,
+ 'billing_address_json' => $checkout->billing_address_json,
+ 'shipping_address_json' => $checkout->shipping_address_json,
+ 'placed_at' => now(),
+ ]);
+
+ $taxDetailsByVariant = collect($checkout->tax_provider_snapshot_json['lines'] ?? [])->keyBy('variant_id');
+ $taxName = (string) ($totals['tax_lines'][0]['name'] ?? 'Tax');
+ $codeDiscount = $this->resolveCodeDiscount($checkout);
+
+ foreach ($cart->lines as $line) {
+ $variant = $line->variant;
+ $taxDetail = $taxDetailsByVariant->get($line->variant_id);
+
+ $order->lines()->create([
+ 'product_id' => $variant?->product_id,
+ 'variant_id' => $line->variant_id,
+ 'title_snapshot' => $this->titleSnapshot($line),
+ 'sku_snapshot' => $variant?->sku,
+ 'quantity' => $line->quantity,
+ 'unit_price_amount' => $line->unit_price_amount,
+ 'total_amount' => $line->line_total_amount,
+ 'tax_lines_json' => ($taxDetail !== null && (int) ($taxDetail['tax_amount'] ?? 0) > 0)
+ ? [['title' => $taxName, 'rate' => (int) $taxDetail['rate'], 'amount' => (int) $taxDetail['tax_amount']]]
+ : [],
+ 'discount_allocations_json' => ($codeDiscount !== null && $line->line_discount_amount > 0)
+ ? [['discount_id' => $codeDiscount->id, 'amount' => $line->line_discount_amount]]
+ : [],
+ ]);
+ }
+
+ $this->payments->recordPayment($order, $method, $paymentResult, $paymentDetails);
+
+ if ($instantCapture) {
+ foreach ($cart->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->commit($item, $line->quantity);
+ }
+ }
+ }
+
+ if ($codeDiscount !== null) {
+ $codeDiscount->increment('usage_count');
+ }
+
+ $cart->forceFill(['status' => CartStatus::Converted])->save();
+ $checkout->forceFill(['status' => CheckoutStatus::Completed])->save();
+
+ if ($order->customer_id === null && $checkout->email !== null) {
+ $order->forceFill([
+ 'customer_id' => $this->linkGuestToCustomer($checkout->email, $checkout->store_id)->id,
+ ])->save();
+ }
+
+ $order = $order->refresh();
+
+ if ($instantCapture && $order->isDigital()) {
+ $this->fulfillments->autoFulfillDigital($order);
+ }
+
+ OrderCreated::dispatch($order);
+ CheckoutCompleted::dispatch($checkout);
+
+ if ($instantCapture) {
+ OrderPaid::dispatch($order->refresh());
+ }
+
+ return $order->refresh();
+ });
+ }
+
+ /**
+ * Next sequential order number for the store (spec 05 §11.2): configured
+ * prefix (default "#") + max numeric suffix + 1, starting at the
+ * configured start (default 1001). Runs inside the creation transaction.
+ */
+ public function generateOrderNumber(Store $store): string
+ {
+ $settings = $store->settings?->settings_json ?? [];
+ $prefix = (string) ($settings['order_number_prefix'] ?? '#');
+ $start = (int) ($settings['order_number_start'] ?? 1001);
+
+ $max = Order::withoutGlobalScope(StoreScope::class)
+ ->where('store_id', $store->id)
+ ->where('order_number', 'like', $prefix.'%')
+ ->selectRaw('MAX(CAST(SUBSTR(order_number, ?) AS INTEGER)) as aggregate', [mb_strlen($prefix) + 1])
+ ->value('aggregate');
+
+ $next = $max === null ? $start : max($start, (int) $max + 1);
+
+ return $prefix.$next;
+ }
+
+ /**
+ * Cancel an order before fulfillment: releases reserved inventory (bank
+ * transfer orders whose stock is still reserved), marks the order
+ * cancelled and dispatches OrderCancelled (spec 05 §11).
+ *
+ * @throws InvalidOrderTransitionException already fulfilled/cancelled/refunded
+ */
+ public function cancel(Order $order, string $reason): void
+ {
+ if (in_array($order->status, [OrderStatus::Fulfilled, OrderStatus::Cancelled, OrderStatus::Refunded], true)
+ || $order->fulfillment_status === FulfillmentOrderStatus::Fulfilled) {
+ throw InvalidOrderTransitionException::make($order->status->value, 'cancel');
+ }
+
+ DB::transaction(function () use ($order): void {
+ $awaitingPayment = $order->financial_status === FinancialStatus::Pending;
+
+ if ($awaitingPayment) {
+ $order->loadMissing('lines.variant.inventoryItem');
+
+ foreach ($order->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->release($item, $line->quantity);
+ }
+ }
+
+ $order->financial_status = FinancialStatus::Voided;
+ }
+
+ $order->status = OrderStatus::Cancelled;
+ $order->save();
+
+ $order->payments()
+ ->where('status', PaymentStatus::Pending->value)
+ ->update(['status' => PaymentStatus::Failed->value]);
+ });
+
+ OrderCancelled::dispatch($order, $reason);
+ }
+
+ /**
+ * Confirm a bank transfer payment was received (spec 05 §10.7): captures
+ * the payment, marks the order paid, commits the reserved inventory and
+ * auto-fulfills digital orders.
+ *
+ * @throws InvalidOrderTransitionException wrong method or not pending
+ */
+ public function confirmBankTransferPayment(Order $order): void
+ {
+ if ($order->payment_method !== PaymentMethod::BankTransfer) {
+ throw InvalidOrderTransitionException::make($order->payment_method->value, 'confirmBankTransferPayment');
+ }
+
+ if ($order->financial_status !== FinancialStatus::Pending) {
+ throw InvalidOrderTransitionException::make($order->financial_status->value, 'confirmBankTransferPayment');
+ }
+
+ DB::transaction(function () use ($order): void {
+ $order->payments()
+ ->where('status', PaymentStatus::Pending->value)
+ ->update(['status' => PaymentStatus::Captured->value]);
+
+ $order->forceFill([
+ 'financial_status' => FinancialStatus::Paid,
+ 'status' => OrderStatus::Paid,
+ ])->save();
+
+ $order->loadMissing('lines.variant.inventoryItem');
+
+ foreach ($order->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->commit($item, $line->quantity);
+ }
+ }
+
+ if ($order->isDigital()) {
+ $this->fulfillments->autoFulfillDigital($order);
+ }
+ });
+
+ OrderPaid::dispatch($order->refresh());
+ }
+
+ /**
+ * Link the checkout email to a customer account (spec 05 §12.4): reuse
+ * the store's customer with that email, or create a password-less guest
+ * customer that can later claim the account.
+ */
+ private function linkGuestToCustomer(string $email, int $storeId): Customer
+ {
+ $customer = Customer::withoutGlobalScope(StoreScope::class)
+ ->where('store_id', $storeId)
+ ->where('email', $email)
+ ->first();
+
+ if ($customer !== null) {
+ return $customer;
+ }
+
+ return Customer::create([
+ 'store_id' => $storeId,
+ 'email' => $email,
+ 'password_hash' => null,
+ 'marketing_opt_in' => false,
+ ]);
+ }
+
+ /**
+ * Build the line title snapshot: product title plus variant option labels
+ * (spec 05 §6.2 step 6).
+ */
+ private function titleSnapshot(CartLine $line): string
+ {
+ $variant = $line->variant;
+ $title = $variant?->product?->title ?? 'Unknown product';
+ $variantTitle = $variant?->title();
+
+ if ($variantTitle !== null && $variantTitle !== 'Default') {
+ $title .= ' - '.$variantTitle;
+ }
+
+ return $title;
+ }
+
+ /**
+ * Resolve the checkout's discount code to the store's discount record.
+ */
+ private function resolveCodeDiscount(Checkout $checkout): ?Discount
+ {
+ if ($checkout->discount_code === null) {
+ return null;
+ }
+
+ return Discount::query()
+ ->where('store_id', $checkout->store_id)
+ ->whereRaw('lower(code) = ?', [mb_strtolower($checkout->discount_code)])
+ ->first();
+ }
+}
diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php
new file mode 100644
index 00000000..f21c3d44
--- /dev/null
+++ b/app/Services/PaymentService.php
@@ -0,0 +1,83 @@
+ $details
+ */
+ public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult
+ {
+ return $this->provider->charge($checkout, $method, $details);
+ }
+
+ /**
+ * Refund (part of) a payment.
+ */
+ public function refund(Payment $payment, int $amount): RefundResult
+ {
+ return $this->provider->refund($payment, $amount);
+ }
+
+ /**
+ * Persist the payment record for a freshly created order.
+ *
+ * @param array $details original charge details (sanitized before storage)
+ */
+ public function recordPayment(Order $order, PaymentMethod $method, PaymentResult $result, array $details = []): Payment
+ {
+ return $order->payments()->create([
+ 'provider' => 'mock',
+ 'method' => $method,
+ 'provider_payment_id' => $result->referenceId,
+ 'status' => $result->status === 'pending' ? PaymentStatus::Pending : PaymentStatus::Captured,
+ 'amount' => $order->total_amount,
+ 'currency' => $order->currency,
+ 'raw_json_encrypted' => $this->sanitizeRawPayload($method, $result, $details),
+ ]);
+ }
+
+ /**
+ * Build the encrypted-at-rest payload, keeping only non-sensitive data.
+ *
+ * @param array $details
+ * @return array
+ */
+ private function sanitizeRawPayload(PaymentMethod $method, PaymentResult $result, array $details): array
+ {
+ $payload = [
+ 'provider' => 'mock',
+ 'method' => $method->value,
+ 'reference_id' => $result->referenceId,
+ 'status' => $result->status,
+ ];
+
+ if ($method === PaymentMethod::CreditCard) {
+ $number = str_replace(' ', '', (string) ($details['card_number'] ?? ''));
+ $payload['card_last4'] = $number !== '' ? substr($number, -4) : null;
+ $payload['card_holder'] = $details['card_holder'] ?? null;
+ }
+
+ return $payload;
+ }
+}
diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php
new file mode 100644
index 00000000..63dc9bac
--- /dev/null
+++ b/app/Services/Payments/MockPaymentProvider.php
@@ -0,0 +1,78 @@
+ $details
+ */
+ public function charge(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult
+ {
+ return match ($method) {
+ PaymentMethod::CreditCard => $this->chargeCard($details),
+ PaymentMethod::Paypal => PaymentResult::captured($this->referenceId()),
+ PaymentMethod::BankTransfer => PaymentResult::pending($this->referenceId()),
+ };
+ }
+
+ /**
+ * Mock refund: always succeeds.
+ */
+ public function refund(Payment $payment, int $amount): RefundResult
+ {
+ return new RefundResult(
+ success: true,
+ providerRefundId: 'mock_refund_'.Str::random(16),
+ status: 'processed',
+ );
+ }
+
+ /**
+ * Evaluate the magic card number (spaces are stripped first).
+ *
+ * @param array $details
+ */
+ private function chargeCard(array $details): PaymentResult
+ {
+ $number = str_replace(' ', '', (string) ($details['card_number'] ?? ''));
+
+ return match ($number) {
+ self::CARD_DECLINED => PaymentResult::failed('card_declined', 'Your card was declined.'),
+ self::CARD_INSUFFICIENT_FUNDS => PaymentResult::failed('insufficient_funds', 'Your card has insufficient funds.'),
+ default => PaymentResult::captured($this->referenceId()),
+ };
+ }
+
+ /**
+ * Generate a mock reference ID.
+ */
+ private function referenceId(): string
+ {
+ return 'mock_'.Str::random(16);
+ }
+}
diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php
new file mode 100644
index 00000000..d1525099
--- /dev/null
+++ b/app/Services/RefundService.php
@@ -0,0 +1,87 @@
+refundableAmount();
+
+ if ($amount <= 0 || $amount > $refundable) {
+ throw ValidationException::withMessages([
+ 'amount' => ["The refund amount exceeds the refundable amount of {$refundable}."],
+ ]);
+ }
+
+ return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock): Refund {
+ $result = $this->payments->refund($payment, $amount);
+
+ $refund = $order->refunds()->create([
+ 'payment_id' => $payment->id,
+ 'amount' => $amount,
+ 'reason' => $reason,
+ 'status' => RefundStatus::Processed,
+ 'provider_refund_id' => $result->providerRefundId,
+ ]);
+
+ $totalRefunded = (int) $order->refunds()
+ ->where('status', RefundStatus::Processed->value)
+ ->sum('amount');
+
+ if ($totalRefunded >= $order->total_amount) {
+ $order->forceFill([
+ 'financial_status' => FinancialStatus::Refunded,
+ 'status' => OrderStatus::Refunded,
+ ])->save();
+
+ $payment->forceFill(['status' => PaymentStatus::Refunded])->save();
+ } else {
+ $order->forceFill(['financial_status' => FinancialStatus::PartiallyRefunded])->save();
+ }
+
+ if ($restock) {
+ $order->loadMissing('lines.variant.inventoryItem');
+
+ foreach ($order->lines as $line) {
+ $item = $line->variant?->inventoryItem;
+
+ if ($item !== null) {
+ $this->inventory->restock($item, $line->quantity);
+ }
+ }
+ }
+
+ OrderRefunded::dispatch($order->refresh(), $refund);
+
+ return $refund;
+ });
+ }
+}
diff --git a/app/Support/OrderToken.php b/app/Support/OrderToken.php
new file mode 100644
index 00000000..bb8bd046
--- /dev/null
+++ b/app/Support/OrderToken.php
@@ -0,0 +1,30 @@
+id.$order->order_number, (string) config('app.key'));
+ }
+
+ /**
+ * Validate a token against the order (constant-time comparison).
+ */
+ public static function validate(Order $order, ?string $token): bool
+ {
+ return is_string($token) && $token !== '' && hash_equals(self::for($order), $token);
+ }
+}
diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php
index d26b594f..ca82076f 100644
--- a/app/Traits/ChecksStoreRole.php
+++ b/app/Traits/ChecksStoreRole.php
@@ -19,6 +19,10 @@ protected function getStoreRole(User $user, int $storeId): ?StoreUserRole
->where('user_id', $user->getKey())
->value('role');
+ if ($role instanceof StoreUserRole) {
+ return $role;
+ }
+
return $role === null ? null : StoreUserRole::from($role);
}
diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php
new file mode 100644
index 00000000..a1a0636b
--- /dev/null
+++ b/app/ValueObjects/PaymentResult.php
@@ -0,0 +1,48 @@
+
+ */
+class CustomerAddressFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'customer_id' => Customer::factory(),
+ 'label' => fake()->randomElement(['Home', 'Work']),
+ 'address_json' => [
+ 'first_name' => fake()->firstName(),
+ 'last_name' => fake()->lastName(),
+ 'company' => null,
+ 'address1' => fake()->streetAddress(),
+ 'address2' => null,
+ 'city' => fake()->city(),
+ 'province' => null,
+ 'province_code' => null,
+ 'country' => 'Germany',
+ 'country_code' => 'DE',
+ 'postal_code' => fake()->postcode(),
+ 'phone' => null,
+ ],
+ 'is_default' => false,
+ ];
+ }
+
+ /**
+ * Indicate that the address is the customer's default.
+ */
+ public function default(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'is_default' => true,
+ ]);
+ }
+}
diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php
new file mode 100644
index 00000000..2be2cfbb
--- /dev/null
+++ b/database/factories/FulfillmentFactory.php
@@ -0,0 +1,52 @@
+
+ */
+class FulfillmentFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'order_id' => Order::factory(),
+ 'status' => FulfillmentShipmentStatus::Pending,
+ 'tracking_company' => null,
+ 'tracking_number' => null,
+ 'tracking_url' => null,
+ 'shipped_at' => null,
+ ];
+ }
+
+ /**
+ * Indicate that the fulfillment has shipped.
+ */
+ public function shipped(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => FulfillmentShipmentStatus::Shipped,
+ 'shipped_at' => now(),
+ ]);
+ }
+
+ /**
+ * Indicate that the fulfillment has been delivered.
+ */
+ public function delivered(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => FulfillmentShipmentStatus::Delivered,
+ 'shipped_at' => now(),
+ ]);
+ }
+}
diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php
new file mode 100644
index 00000000..a8a4519f
--- /dev/null
+++ b/database/factories/FulfillmentLineFactory.php
@@ -0,0 +1,27 @@
+
+ */
+class FulfillmentLineFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'fulfillment_id' => Fulfillment::factory(),
+ 'order_line_id' => OrderLine::factory(),
+ 'quantity' => 1,
+ ];
+ }
+}
diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php
new file mode 100644
index 00000000..ed5380a8
--- /dev/null
+++ b/database/factories/OrderFactory.php
@@ -0,0 +1,116 @@
+
+ */
+class OrderFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'store_id' => Store::factory(),
+ 'checkout_id' => null,
+ 'customer_id' => null,
+ 'order_number' => '#'.fake()->unique()->numberBetween(1001, 999999),
+ 'payment_method' => PaymentMethod::CreditCard,
+ 'status' => OrderStatus::Pending,
+ 'financial_status' => FinancialStatus::Pending,
+ 'fulfillment_status' => FulfillmentOrderStatus::Unfulfilled,
+ 'currency' => 'USD',
+ 'subtotal_amount' => 0,
+ 'discount_amount' => 0,
+ 'shipping_amount' => 0,
+ 'tax_amount' => 0,
+ 'total_amount' => 0,
+ 'email' => fake()->safeEmail(),
+ 'billing_address_json' => null,
+ 'shipping_address_json' => null,
+ 'placed_at' => now(),
+ ];
+ }
+
+ /**
+ * Indicate that the order is paid (instant capture).
+ */
+ public function paid(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => OrderStatus::Paid,
+ 'financial_status' => FinancialStatus::Paid,
+ ]);
+ }
+
+ /**
+ * Indicate that the order awaits a bank transfer payment.
+ */
+ public function bankTransfer(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'payment_method' => PaymentMethod::BankTransfer,
+ 'status' => OrderStatus::Pending,
+ 'financial_status' => FinancialStatus::Pending,
+ ]);
+ }
+
+ /**
+ * Indicate that the order is fulfilled.
+ */
+ public function fulfilled(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'status' => OrderStatus::Fulfilled,
+ 'fulfillment_status' => FulfillmentOrderStatus::Fulfilled,
+ ]);
+ }
+
+ /**
+ * Create order lines with the given quantities.
+ *
+ * @param array $lines
+ */
+ public function withLines(array $lines): static
+ {
+ return $this->afterCreating(function (Order $order) use ($lines): void {
+ $subtotal = 0;
+
+ foreach ($lines as $line) {
+ $quantity = (int) ($line['quantity'] ?? 1);
+ $unitPrice = (int) ($line['unit_price_amount'] ?? 1000);
+ $total = $quantity * $unitPrice;
+ $subtotal += $total;
+
+ $order->lines()->create([
+ 'product_id' => $line['product_id'] ?? null,
+ 'variant_id' => $line['variant_id'] ?? null,
+ 'title_snapshot' => $line['title_snapshot'] ?? fake()->words(2, true),
+ 'sku_snapshot' => $line['sku_snapshot'] ?? fake()->bothify('SKU-####'),
+ 'quantity' => $quantity,
+ 'unit_price_amount' => $unitPrice,
+ 'total_amount' => $total,
+ 'tax_lines_json' => $line['tax_lines_json'] ?? [],
+ 'discount_allocations_json' => $line['discount_allocations_json'] ?? [],
+ ]);
+ }
+
+ $order->forceFill([
+ 'subtotal_amount' => $subtotal,
+ 'total_amount' => $subtotal + $order->tax_amount + $order->shipping_amount - $order->discount_amount,
+ ])->save();
+ });
+ }
+}
diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php
new file mode 100644
index 00000000..48b2e0d3
--- /dev/null
+++ b/database/factories/OrderLineFactory.php
@@ -0,0 +1,36 @@
+
+ */
+class OrderLineFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ $unitPrice = fake()->numberBetween(100, 10000);
+ $quantity = fake()->numberBetween(1, 3);
+
+ return [
+ 'order_id' => Order::factory(),
+ 'product_id' => null,
+ 'variant_id' => null,
+ 'title_snapshot' => fake()->words(3, true),
+ 'sku_snapshot' => fake()->bothify('SKU-####'),
+ 'quantity' => $quantity,
+ 'unit_price_amount' => $unitPrice,
+ 'total_amount' => $unitPrice * $quantity,
+ 'tax_lines_json' => [],
+ 'discount_allocations_json' => [],
+ ];
+ }
+}
diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php
new file mode 100644
index 00000000..15fc3055
--- /dev/null
+++ b/database/factories/PaymentFactory.php
@@ -0,0 +1,45 @@
+
+ */
+class PaymentFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'order_id' => Order::factory(),
+ 'provider' => 'mock',
+ 'method' => PaymentMethod::CreditCard,
+ 'provider_payment_id' => 'mock_'.Str::random(16),
+ 'status' => PaymentStatus::Captured,
+ 'amount' => fn (array $attributes) => Order::find($attributes['order_id'])?->total_amount ?? 0,
+ 'currency' => 'USD',
+ 'raw_json_encrypted' => null,
+ ];
+ }
+
+ /**
+ * Indicate that the payment is pending (bank transfer).
+ */
+ public function pending(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'method' => PaymentMethod::BankTransfer,
+ 'status' => PaymentStatus::Pending,
+ ]);
+ }
+}
diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php
new file mode 100644
index 00000000..a970ed37
--- /dev/null
+++ b/database/factories/RefundFactory.php
@@ -0,0 +1,31 @@
+
+ */
+class RefundFactory extends Factory
+{
+ /**
+ * Define the model's default state.
+ *
+ * @return array
+ */
+ public function definition(): array
+ {
+ return [
+ 'order_id' => fn (array $attributes) => Payment::find($attributes['payment_id'])?->order_id,
+ 'payment_id' => Payment::factory(),
+ 'amount' => 1000,
+ 'reason' => null,
+ 'status' => RefundStatus::Processed,
+ 'provider_refund_id' => 'mock_refund_'.Str::random(16),
+ ];
+ }
+}
diff --git a/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php b/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php
new file mode 100644
index 00000000..9990a630
--- /dev/null
+++ b/database/migrations/2026_07_19_000001_add_checkout_id_to_orders_table.php
@@ -0,0 +1,34 @@
+foreignId('checkout_id')->nullable()->constrained()->nullOnDelete();
+ $table->index('checkout_id', 'idx_orders_checkout_id');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('orders', function (Blueprint $table) {
+ $table->dropIndex('idx_orders_checkout_id');
+ $table->dropConstrainedForeignId('checkout_id');
+ });
+ }
+};
diff --git a/resources/views/livewire/storefront/checkout/confirmation.blade.php b/resources/views/livewire/storefront/checkout/confirmation.blade.php
new file mode 100644
index 00000000..df003dd2
--- /dev/null
+++ b/resources/views/livewire/storefront/checkout/confirmation.blade.php
@@ -0,0 +1,122 @@
+@php
+ $order = $this->order;
+ $address = $order->shipping_address_json ?? [];
+ $payment = $order->payments->first();
+@endphp
+
+
+ {{-- Success header (spec 04 §9.1) --}}
+
+
+
Thank you for your order!
+
Order {{ $order->order_number }}
+
We've sent a confirmation to {{ $order->email }}
+
+
+ {{-- Items (spec 04 §9.2) --}}
+
+
+ {{-- Address & payment (spec 04 §9.2) --}}
+
+
+ Shipping Address
+
+ {{ $address['first_name'] ?? '' }} {{ $address['last_name'] ?? '' }}
+ {{ $address['address1'] ?? '' }}
+ {{ $address['postal_code'] ?? '' }} {{ $address['city'] ?? '' }}
+ {{ $address['country_code'] ?? $address['country'] ?? '' }}
+
+
+
+ Payment Method
+
+ @if ($order->payment_method->value === 'credit_card')
+ Credit Card @if ($paymentLast4) ending in {{ $paymentLast4 }} @endif
+ @elseif ($order->payment_method->value === 'paypal')
+ PayPal
+ @else
+ Bank Transfer
+ @endif
+
+
+
+
+ {{-- Bank transfer instructions (spec 04 §9.2) --}}
+ @if ($isBankTransfer)
+
+
+
+
+
+ Bank Transfer Instructions
+
+ Please transfer the total amount to the following account:
+
+
Bank Mock Bank AG
+
IBAN DE89 3704 0044 0532 0130 00
+
BIC COBADEFFXXX
+
Amount {{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
Reference {{ $order->order_number }}
+
+ Please complete your transfer within 7 days. Your order will be processed once payment is confirmed by our team.
+
+ @endif
+
+ {{-- Totals --}}
+
+
+
Subtotal
+ {{ \App\Support\Money::format($order->subtotal_amount, $order->currency) }}
+
+ @if ($order->discount_amount > 0)
+
+
Discount
+ -{{ \App\Support\Money::format($order->discount_amount, $order->currency) }}
+
+ @endif
+
+
Shipping
+ {{ \App\Support\Money::format($order->shipping_amount, $order->currency) }}
+
+
+
Tax
+ {{ \App\Support\Money::format($order->tax_amount, $order->currency) }}
+
+
+
Total
+ {{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+
+ {{-- Actions (spec 04 §9.3) --}}
+
+
diff --git a/resources/views/livewire/storefront/checkout/show.blade.php b/resources/views/livewire/storefront/checkout/show.blade.php
index 1ee400ee..605ce3f9 100644
--- a/resources/views/livewire/storefront/checkout/show.blade.php
+++ b/resources/views/livewire/storefront/checkout/show.blade.php
@@ -159,8 +159,8 @@ class="flex w-full items-center justify-between gap-4 rounded-md border border-g
Select a payment method
@foreach (['credit_card' => 'Credit Card', 'paypal' => 'PayPal', 'bank_transfer' => 'Bank Transfer'] as $value => $label)
-
-
+
+
{{ $label }}
@endforeach
@@ -174,14 +174,65 @@ class="rounded-md bg-blue-600 px-4 py-3 text-sm font-semibold text-white hover:b
Reserving...
@else
-
- Payment method saved and inventory reserved.
-
-
- Pay now - {{ \App\Support\Money::format($summaryTotal, $summaryCurrency) }}
-
- Payment processing arrives with Phase 5. Your order cannot be placed yet.
+ @php $storedMethod = $checkout?->payment_method?->value ?? $paymentMethod; @endphp
+
+
@endif
@endif
diff --git a/routes/api.php b/routes/api.php
index 514a6277..b971125f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,6 +2,7 @@
use App\Http\Controllers\Api\Storefront\CartController;
use App\Http\Controllers\Api\Storefront\CheckoutController;
+use App\Http\Controllers\Api\Storefront\OrderController;
use Illuminate\Support\Facades\Route;
// Storefront API (cart, checkout, search). Endpoints are added in later phases.
@@ -24,7 +25,11 @@
Route::put('/checkouts/{checkoutId}/payment-method', [CheckoutController::class, 'selectPaymentMethod']);
Route::post('/checkouts/{checkoutId}/apply-discount', [CheckoutController::class, 'applyDiscount']);
Route::delete('/checkouts/{checkoutId}/discount', [CheckoutController::class, 'removeDiscount']);
+ Route::post('/checkouts/{checkoutId}/pay', [CheckoutController::class, 'pay']);
});
+
+ // Order status endpoint (spec 02 §2.4), token-authenticated.
+ Route::get('/orders/{orderNumber}', [OrderController::class, 'show']);
});
// Admin REST API (Sanctum personal access tokens). Endpoints are added in later phases.
diff --git a/routes/console.php b/routes/console.php
index a8ef7a2f..28593729 100644
--- a/routes/console.php
+++ b/routes/console.php
@@ -1,5 +1,6 @@
everyFifteenMinutes();
Schedule::job(new CleanupAbandonedCarts)->daily();
+
+// Cancel bank transfer orders that remain unpaid (spec 05 §10.8).
+Schedule::job(new CancelUnpaidBankTransferOrders)->daily();
diff --git a/routes/web.php b/routes/web.php
index 7ea408ea..53e3d05a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -1,6 +1,7 @@
name('storefront.products.show');
Route::livewire('/cart', CartShow::class)->name('storefront.cart.show');
Route::livewire('/checkout/{checkoutId}', CheckoutShow::class)->name('storefront.checkout.show');
+ Route::livewire('/checkout/{checkoutId}/confirmation', CheckoutConfirmation::class)->name('storefront.checkout.confirmation');
Route::livewire('/pages/{handle}', PagesShow::class)->name('storefront.pages.show');
});
diff --git a/tests/Feature/Checkout/CheckoutPaymentApiTest.php b/tests/Feature/Checkout/CheckoutPaymentApiTest.php
new file mode 100644
index 00000000..e8b6bf3b
--- /dev/null
+++ b/tests/Feature/Checkout/CheckoutPaymentApiTest.php
@@ -0,0 +1,295 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Store with variant (2500, 10 on hand), DE zone with flat rate 499.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: ShippingRate}
+ */
+function paySetup(): array
+{
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+/**
+ * Drive a checkout to payment_selected with the given method (qty 2).
+ */
+function payCheckout(Store $store, ProductVariant $variant, ShippingRate $rate, string $method = 'credit_card'): Checkout
+{
+ $service = app(CheckoutService::class);
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ return $service->selectPaymentMethod($checkout, $method);
+}
+
+test('pays with credit card and returns the order payload', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $response = $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('checkout_id', $checkout->id)
+ ->assertJsonPath('status', 'completed')
+ ->assertJsonPath('order.order_number', '#1001')
+ ->assertJsonPath('order.status', 'paid')
+ ->assertJsonPath('order.financial_status', 'paid')
+ ->assertJsonPath('order.payment_method', 'credit_card')
+ ->assertJsonPath('order.total_amount', 5499);
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed);
+});
+
+test('returns 422 with error code when card is declined', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4000000000000002',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertUnprocessable()
+ ->assertJsonPath('error_code', 'card_declined')
+ ->assertJsonPath('message', 'Your card was declined.');
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::PaymentSelected)
+ ->and(Order::withoutGlobalScopes()->count())->toBe(0)
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0);
+});
+
+test('can retry with a different card after a decline', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4000000000000002',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertUnprocessable();
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertOk()
+ ->assertJsonPath('status', 'completed');
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(8)
+ ->and($item->quantity_reserved)->toBe(0);
+});
+
+test('returns 422 with insufficient funds error code', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4000000000009995',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertUnprocessable()
+ ->assertJsonPath('error_code', 'insufficient_funds');
+});
+
+test('pays with bank transfer and returns instructions', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate, 'bank_transfer');
+
+ $response = $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'bank_transfer',
+ ]);
+
+ $response->assertOk()
+ ->assertJsonPath('status', 'completed')
+ ->assertJsonPath('order.status', 'pending')
+ ->assertJsonPath('order.financial_status', 'pending')
+ ->assertJsonPath('order.payment_method', 'bank_transfer')
+ ->assertJsonPath('bank_transfer_instructions.bank_name', 'Mock Bank AG')
+ ->assertJsonPath('bank_transfer_instructions.iban', 'DE89 3704 0044 0532 0130 00')
+ ->assertJsonPath('bank_transfer_instructions.bic', 'COBADEFFXXX')
+ ->assertJsonPath('bank_transfer_instructions.reference', '#1001')
+ ->assertJsonPath('bank_transfer_instructions.amount_formatted', '54.99 USD');
+
+ // Inventory stays reserved while awaiting payment.
+ expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2)
+ ->and($variant->inventoryItem->quantity_on_hand)->toBe(10);
+});
+
+test('pays with paypal and captures immediately', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate, 'paypal');
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'paypal',
+ ])->assertOk()
+ ->assertJsonPath('order.status', 'paid')
+ ->assertJsonPath('order.payment_method', 'paypal');
+});
+
+test('returns 409 when checkout is not in payment selected state', function () {
+ [$store, $variant, $rate] = paySetup();
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertConflict();
+});
+
+test('returns 410 when the checkout has expired', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+ $checkout->update(['expires_at' => now()->subHour()]);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertGone();
+});
+
+test('returns 422 when credit card fields are missing', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ ])->assertUnprocessable()
+ ->assertJsonValidationErrors(['card_number', 'card_expiry', 'card_cvc', 'card_holder']);
+});
+
+test('double submit returns the same order without duplicates', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $payload = [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ];
+
+ $first = $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), $payload);
+ $second = $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), $payload);
+
+ $first->assertOk();
+ $second->assertOk();
+
+ expect($second->json('order.id'))->toBe($first->json('order.id'))
+ ->and(Order::withoutGlobalScopes()->count())->toBe(1);
+});
+
+test('confirmation page renders the placed order', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate);
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'credit_card',
+ 'card_number' => '4242424242424242',
+ 'card_expiry' => '12/28',
+ 'card_cvc' => '123',
+ 'card_holder' => 'Jane Doe',
+ ])->assertOk();
+
+ $this->get('http://'.$store->handle.'.test/checkout/'.$checkout->id.'/confirmation')
+ ->assertOk()
+ ->assertSee('#1001')
+ ->assertSee('customer@example.com')
+ ->assertSee('ending in 4242');
+});
+
+test('confirmation page shows bank transfer instructions', function () {
+ [$store, $variant, $rate] = paySetup();
+ $checkout = payCheckout($store, $variant, $rate, 'bank_transfer');
+
+ $this->postJson(payApiUrl($store, "/checkouts/{$checkout->id}/pay"), [
+ 'payment_method' => 'bank_transfer',
+ ])->assertOk();
+
+ $this->get('http://'.$store->handle.'.test/checkout/'.$checkout->id.'/confirmation')
+ ->assertOk()
+ ->assertSee('Bank Transfer Instructions')
+ ->assertSee('DE89 3704 0044 0532 0130 00')
+ ->assertSee('#1001');
+});
diff --git a/tests/Feature/Checkout/OrderStatusApiTest.php b/tests/Feature/Checkout/OrderStatusApiTest.php
new file mode 100644
index 00000000..2578169a
--- /dev/null
+++ b/tests/Feature/Checkout/OrderStatusApiTest.php
@@ -0,0 +1,117 @@
+handle.'.test/api/storefront/v1'.$path;
+}
+
+/**
+ * Create a paid order via the full checkout flow.
+ *
+ * @return array{0: Store, 1: Order}
+ */
+function placedOrder(?Store $store = null): array
+{
+ $store ??= test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::query()->first()
+ ?? ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::query()->where('zone_id', $zone->id)->first()
+ ?? ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ return [$store, $order];
+}
+
+test('returns the order for a valid token', function () {
+ [$store, $order] = placedOrder();
+
+ $url = orderApiUrl($store, '/orders/'.urlencode($order->order_number).'?token='.OrderToken::for($order));
+
+ $this->getJson($url)
+ ->assertOk()
+ ->assertJsonPath('order_number', $order->order_number)
+ ->assertJsonPath('status', 'paid')
+ ->assertJsonPath('financial_status', 'paid')
+ ->assertJsonPath('fulfillment_status', 'unfulfilled')
+ ->assertJsonPath('email', 'customer@example.com')
+ ->assertJsonPath('totals.total_amount', 5499)
+ ->assertJsonPath('totals.subtotal_amount', 5000)
+ ->assertJsonPath('lines.0.quantity', 2)
+ ->assertJsonPath('lines.0.unit_price_amount', 2500)
+ ->assertJsonPath('shipping_address.city', 'Berlin')
+ ->assertJsonStructure(['placed_at', 'lines', 'totals', 'shipping_address', 'fulfillments']);
+});
+
+test('rejects a missing token', function () {
+ [$store, $order] = placedOrder();
+
+ $this->getJson(orderApiUrl($store, '/orders/'.urlencode($order->order_number)))
+ ->assertUnauthorized();
+});
+
+test('rejects an invalid token', function () {
+ [$store, $order] = placedOrder();
+
+ $this->getJson(orderApiUrl($store, '/orders/'.urlencode($order->order_number).'?token=forged-token'))
+ ->assertUnauthorized();
+});
+
+test('returns 404 for an unknown order number', function () {
+ [$store, $order] = placedOrder();
+
+ $this->getJson(orderApiUrl($store, '/orders/'.urlencode('#9999').'?token=whatever'))
+ ->assertNotFound();
+});
+
+test('does not leak orders across stores', function () {
+ [$storeA] = placedOrder();
+ [$storeB] = placedOrder();
+ [, $orderB] = placedOrder($storeB); // second order in store B gets #1002
+
+ // Store B's #1002 does not exist in store A (only #1001) — the store
+ // scope must hide it even with a valid token for that order.
+ $url = orderApiUrl($storeA, '/orders/'.urlencode($orderB->order_number).'?token='.OrderToken::for($orderB));
+
+ $this->getJson($url)->assertNotFound();
+});
diff --git a/tests/Feature/Orders/FulfillmentTest.php b/tests/Feature/Orders/FulfillmentTest.php
new file mode 100644
index 00000000..800ce56c
--- /dev/null
+++ b/tests/Feature/Orders/FulfillmentTest.php
@@ -0,0 +1,224 @@
+createStore();
+ test()->bindStore($store);
+
+ $order = Order::factory()->paid()->withLines([
+ ['quantity' => 3, 'unit_price_amount' => 1000],
+ ['quantity' => 2, 'unit_price_amount' => 500],
+ ])->create(array_merge(['store_id' => $store->id], $attributes));
+
+ return [$store, $order->refresh()];
+}
+
+test('creates a fulfillment for specific order lines', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne, $lineTwo] = $order->lines->values();
+
+ Event::fake([FulfillmentCreated::class]);
+
+ $fulfillment = app(FulfillmentService::class)->create($order, [$lineOne->id => 3]);
+
+ expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending)
+ ->and($fulfillment->lines)->toHaveCount(1)
+ ->and($fulfillment->lines->first()->order_line_id)->toBe($lineOne->id)
+ ->and($fulfillment->lines->first()->quantity)->toBe(3);
+
+ Event::assertDispatched(FulfillmentCreated::class, fn (FulfillmentCreated $event): bool => $event->fulfillment->id === $fulfillment->id);
+});
+
+test('updates order fulfillment status to partial', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne, $lineTwo] = $order->lines->values();
+
+ app(FulfillmentService::class)->create($order, [$lineOne->id => 3]);
+
+ expect($order->refresh()->fulfillment_status)->toBe(FulfillmentOrderStatus::Partial)
+ ->and($order->status)->toBe(OrderStatus::Paid);
+});
+
+test('updates order fulfillment status to fulfilled when all lines done', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne, $lineTwo] = $order->lines->values();
+
+ $service = app(FulfillmentService::class);
+ $service->create($order, [$lineOne->id => 3]);
+
+ Event::fake([OrderFulfilled::class]);
+
+ $service->create($order->refresh(), [$lineTwo->id => 2]);
+
+ expect($order->refresh()->fulfillment_status)->toBe(FulfillmentOrderStatus::Fulfilled)
+ ->and($order->status)->toBe(OrderStatus::Fulfilled);
+
+ Event::assertDispatched(OrderFulfilled::class, fn (OrderFulfilled $event): bool => $event->order->id === $order->id);
+});
+
+test('adds tracking information', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ $fulfillment = app(FulfillmentService::class)->create($order, [$lineOne->id => 1], [
+ 'tracking_company' => 'DHL',
+ 'tracking_number' => '123456',
+ 'tracking_url' => 'https://dhl.example/track/123456',
+ ]);
+
+ expect($fulfillment->tracking_company)->toBe('DHL')
+ ->and($fulfillment->tracking_number)->toBe('123456')
+ ->and($fulfillment->tracking_url)->toBe('https://dhl.example/track/123456');
+});
+
+test('transitions fulfillment from pending to shipped', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ $fulfillment = app(FulfillmentService::class)->create($order, [$lineOne->id => 1]);
+
+ Event::fake([FulfillmentShipped::class]);
+
+ app(FulfillmentService::class)->markAsShipped($fulfillment, [
+ 'tracking_company' => 'DHL',
+ 'tracking_number' => '123456',
+ ]);
+
+ expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Shipped)
+ ->and($fulfillment->shipped_at)->not->toBeNull()
+ ->and($fulfillment->tracking_number)->toBe('123456');
+
+ Event::assertDispatched(FulfillmentShipped::class);
+});
+
+test('transitions fulfillment from shipped to delivered', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ $service = app(FulfillmentService::class);
+ $fulfillment = $service->create($order, [$lineOne->id => 1]);
+ $service->markAsShipped($fulfillment);
+
+ Event::fake([FulfillmentDelivered::class]);
+
+ $service->markAsDelivered($fulfillment->refresh());
+
+ expect($fulfillment->refresh()->status)->toBe(FulfillmentShipmentStatus::Delivered);
+
+ Event::assertDispatched(FulfillmentDelivered::class);
+});
+
+test('prevents fulfilling more than ordered quantity', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ app(FulfillmentService::class)->create($order, [$lineOne->id => 4]);
+})->throws(ValidationException::class);
+
+test('prevents fulfilling more than the remaining unfulfilled quantity', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ $service = app(FulfillmentService::class);
+ $service->create($order, [$lineOne->id => 2]);
+ $service->create($order->refresh(), [$lineOne->id => 2]);
+})->throws(ValidationException::class);
+
+test('fulfillment guard blocks fulfillment when financial status is pending', function () {
+ [$store, $order] = fulfillmentOrder(['financial_status' => FinancialStatus::Pending]);
+ [$lineOne] = $order->lines->values();
+
+ app(FulfillmentService::class)->create($order, [$lineOne->id => 1]);
+})->throws(FulfillmentGuardException::class);
+
+test('fulfillment guard allows fulfillment when financial status is paid', function () {
+ [$store, $order] = fulfillmentOrder();
+ [$lineOne] = $order->lines->values();
+
+ $fulfillment = app(FulfillmentService::class)->create($order, [$lineOne->id => 1]);
+
+ expect($fulfillment)->toBeInstanceOf(App\Models\Fulfillment::class);
+});
+
+test('fulfillment guard allows fulfillment when financial status is partially refunded', function () {
+ [$store, $order] = fulfillmentOrder(['financial_status' => FinancialStatus::PartiallyRefunded]);
+ [$lineOne] = $order->lines->values();
+
+ $fulfillment = app(FulfillmentService::class)->create($order, [$lineOne->id => 1]);
+
+ expect($fulfillment)->toBeInstanceOf(App\Models\Fulfillment::class);
+});
+
+test('auto-fulfills digital products on payment confirmation', function () {
+ $store = test()->createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ 'requires_shipping' => false,
+ ]);
+ ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, null);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->refresh()->fulfillment_status)->toBe(FulfillmentOrderStatus::Fulfilled);
+ $fulfillment = $order->fulfillments->first();
+ expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered);
+});
+
+test('only allows admin, owner, or staff to create fulfillments', function () {
+ [$store, $order] = fulfillmentOrder();
+
+ $support = test()->createUserWithRole($store, 'support');
+ $staff = test()->createUserWithRole($store, 'staff');
+
+ expect(Gate::forUser($support)->inspect('createFulfillment', $order)->denied())->toBeTrue()
+ ->and(Gate::forUser($staff)->inspect('createFulfillment', $order)->allowed())->toBeTrue();
+});
diff --git a/tests/Feature/Orders/OrderCreationTest.php b/tests/Feature/Orders/OrderCreationTest.php
new file mode 100644
index 00000000..95d395ba
--- /dev/null
+++ b/tests/Feature/Orders/OrderCreationTest.php
@@ -0,0 +1,337 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ 'requires_shipping' => ! $digital,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+function orderAddress(): array
+{
+ return [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ];
+}
+
+/**
+ * Drive a checkout to payment_selected via the full state machine.
+ */
+function orderCheckout(Store $store, ProductVariant $variant, ?ShippingRate $rate, string $method = 'credit_card', int $quantity = 2, ?Customer $customer = null, string $email = 'customer@example.com'): Checkout
+{
+ $service = app(CheckoutService::class);
+
+ $cart = app(CartService::class)->create($store, $customer);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $checkout = $service->createFromCart($cart->refresh(), $email, $customer);
+ $checkout = $service->setAddress($checkout, ['shipping_address' => orderAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate?->id);
+
+ return $service->selectPaymentMethod($checkout, $method);
+}
+
+test('creates an order from a completed checkout', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->status)->toBe(OrderStatus::Paid)
+ ->and($order->financial_status)->toBe(FinancialStatus::Paid)
+ ->and($order->fulfillment_status)->toBe(FulfillmentOrderStatus::Unfulfilled)
+ ->and($order->payment_method)->toBe(PaymentMethod::CreditCard)
+ ->and($order->subtotal_amount)->toBe(5000)
+ ->and($order->shipping_amount)->toBe(499)
+ ->and($order->tax_amount)->toBe(0)
+ ->and($order->total_amount)->toBe(5499)
+ ->and($order->order_number)->toBe('#1001')
+ ->and($order->placed_at)->not->toBeNull()
+ ->and($order->shipping_address_json['city'])->toBe('Berlin')
+ ->and($order->isPaid())->toBeTrue();
+
+ $payment = $order->payments->first();
+ expect($payment->provider)->toBe('mock')
+ ->and($payment->status)->toBe(PaymentStatus::Captured)
+ ->and($payment->amount)->toBe(5499);
+});
+
+test('generates sequential order numbers per store', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $service = app(CheckoutService::class);
+
+ $numbers = [];
+ foreach (range(1, 3) as $i) {
+ $checkout = orderCheckout($store, $variant, $rate, 'credit_card', 1);
+ $numbers[] = $service->completeCheckout($checkout, ['card_number' => '4242424242424242'])->order_number;
+ }
+
+ expect($numbers)->toBe(['#1001', '#1002', '#1003']);
+
+ // A second store gets its own sequence.
+ $otherStore = test()->createStore();
+ test()->bindStore($otherStore);
+
+ $otherProduct = Product::factory()->active()->create(['store_id' => $otherStore->id]);
+ $otherVariant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $otherProduct->id,
+ 'price_amount' => 1000,
+ ]);
+ $otherZone = ShippingZone::factory()->create(['store_id' => $otherStore->id, 'countries_json' => ['DE']]);
+ $otherRate = ShippingRate::factory()->flat(100)->create(['zone_id' => $otherZone->id]);
+
+ expect(app(App\Services\OrderService::class)->generateOrderNumber($otherStore))->toBe('#1001');
+
+ $otherCheckout = orderCheckout($otherStore, $otherVariant, $otherRate, 'credit_card', 1);
+ $otherOrder = $service->completeCheckout($otherCheckout, ['card_number' => '4242424242424242']);
+
+ expect($otherOrder->order_number)->toBe('#1001');
+});
+
+test('creates order lines with snapshot data', function () {
+ [$store, $variant, $rate] = orderSetup();
+
+ $option = ProductOption::factory()->create(['product_id' => $variant->product_id, 'name' => 'Color']);
+ $value = ProductOptionValue::factory()->create(['product_option_id' => $option->id, 'value' => 'Blue']);
+ $variant->optionValues()->attach($value->id);
+
+ $checkout = orderCheckout($store, $variant, $rate);
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ $line = $order->lines->first();
+
+ expect($line->title_snapshot)->toBe($variant->product->title.' - Blue')
+ ->and($line->sku_snapshot)->toBe($variant->sku)
+ ->and($line->product_id)->toBe($variant->product_id)
+ ->and($line->variant_id)->toBe($variant->id)
+ ->and($line->quantity)->toBe(2)
+ ->and($line->unit_price_amount)->toBe(2500)
+ ->and($line->total_amount)->toBe(5000)
+ ->and($line->tax_lines_json)->toBe([])
+ ->and($line->discount_allocations_json)->toBe([]);
+});
+
+test('commits inventory on order creation for instant capture methods', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate, 'credit_card', 3);
+
+ expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(3);
+
+ app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(7)
+ ->and($item->quantity_reserved)->toBe(0);
+});
+
+test('keeps inventory reserved for bank transfer orders', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate, 'bank_transfer', 3);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['payment_method' => 'bank_transfer']);
+
+ expect($order->status)->toBe(OrderStatus::Pending)
+ ->and($order->financial_status)->toBe(FinancialStatus::Pending);
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(10)
+ ->and($item->quantity_reserved)->toBe(3);
+});
+
+test('marks cart as converted and checkout as completed', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($checkout->refresh()->status)->toBe(CheckoutStatus::Completed)
+ ->and($checkout->cart->refresh()->status)->toBe(CartStatus::Converted);
+});
+
+test('dispatches order lifecycle events', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ Event::fake([OrderCreated::class, CheckoutCompleted::class, OrderPaid::class]);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ Event::assertDispatched(OrderCreated::class, fn (OrderCreated $event): bool => $event->order->id === $order->id);
+ Event::assertDispatched(CheckoutCompleted::class, fn (CheckoutCompleted $event): bool => $event->checkout->id === $checkout->id);
+ Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->id === $order->id);
+});
+
+test('preserves order data when the variant is deleted', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ $variantSku = $variant->sku;
+ $variant->delete();
+
+ $line = $order->lines()->first();
+ expect($line->variant_id)->toBeNull()
+ ->and($line->sku_snapshot)->toBe($variantSku)
+ ->and($line->title_snapshot)->not->toBeEmpty()
+ ->and($order->refresh()->isDigital())->toBeFalse();
+});
+
+test('links guest order to a customer by email', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ $customer = Customer::withoutGlobalScopes()->where('store_id', $store->id)->where('email', 'customer@example.com')->first();
+
+ expect($customer)->not->toBeNull()
+ ->and($customer->password_hash)->toBeNull()
+ ->and($order->refresh()->customer_id)->toBe($customer->id);
+
+ // A second order with the same email reuses the customer.
+ $second = orderCheckout($store, $variant, $rate);
+ $secondOrder = app(CheckoutService::class)->completeCheckout($second, ['card_number' => '4242424242424242']);
+
+ expect($secondOrder->customer_id)->toBe($customer->id)
+ ->and(Customer::withoutGlobalScopes()->where('store_id', $store->id)->count())->toBe(1);
+});
+
+test('links order to the authenticated customer', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $customer = Customer::factory()->create(['store_id' => $store->id]);
+
+ $checkout = orderCheckout($store, $variant, $rate, 'credit_card', 2, $customer);
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->customer_id)->toBe($customer->id)
+ ->and(Customer::withoutGlobalScopes()->where('store_id', $store->id)->count())->toBe(1);
+});
+
+test('sets email from checkout on the order', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate, 'credit_card', 2, null, 'guest@example.com');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->email)->toBe('guest@example.com');
+});
+
+test('prevents duplicate orders from same checkout', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ $service = app(CheckoutService::class);
+ $first = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+ $second = $service->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']);
+
+ expect($second->id)->toBe($first->id)
+ ->and(Order::withoutGlobalScopes()->count())->toBe(1);
+});
+
+test('increments usage count on order completion', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $discount = Discount::factory()->create([
+ 'store_id' => $store->id,
+ 'code' => 'SAVE10',
+ 'usage_count' => 5,
+ ]);
+
+ $checkout = orderCheckout($store, $variant, $rate);
+
+ $service = app(CheckoutService::class);
+ $result = $service->applyDiscount($checkout, 'SAVE10');
+ expect($result->valid)->toBeTrue();
+
+ $order = $service->completeCheckout($checkout->refresh(), ['card_number' => '4242424242424242']);
+
+ expect($discount->refresh()->usage_count)->toBe(6)
+ ->and($order->discount_amount)->toBe(500);
+
+ $line = $order->lines->first();
+ expect($line->discount_allocations_json)->toBe([['discount_id' => $discount->id, 'amount' => 500]]);
+});
+
+test('completes full checkout happy path', function () {
+ [$store, $variant, $rate] = orderSetup();
+ $service = app(CheckoutService::class);
+
+ // create cart -> add lines -> create checkout -> set address -> select shipping -> begin payment -> complete
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 2);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, ['shipping_address' => orderAddress()]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order)->toBeInstanceOf(Order::class)
+ ->and($cart->refresh()->status)->toBe(CartStatus::Converted)
+ ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8)
+ ->and($variant->inventoryItem->quantity_reserved)->toBe(0);
+});
+
+test('auto-fulfills digital products on instant capture', function () {
+ [$store, $variant] = orderSetup(digital: true);
+ $checkout = orderCheckout($store, $variant, null);
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->refresh()->fulfillment_status)->toBe(FulfillmentOrderStatus::Fulfilled)
+ ->and($order->status)->toBe(OrderStatus::Fulfilled);
+
+ $fulfillment = $order->fulfillments->first();
+ expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered)
+ ->and($fulfillment->shipped_at)->not->toBeNull()
+ ->and($fulfillment->lines)->toHaveCount(1);
+});
diff --git a/tests/Feature/Orders/RefundTest.php b/tests/Feature/Orders/RefundTest.php
new file mode 100644
index 00000000..8c4a5d01
--- /dev/null
+++ b/tests/Feature/Orders/RefundTest.php
@@ -0,0 +1,146 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ return [$store, $variant, $order, $order->payments->first()];
+}
+
+test('creates a full refund', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+
+ Event::fake([OrderRefunded::class]);
+
+ $refund = app(RefundService::class)->create($order, $payment, $order->total_amount, 'Customer changed mind');
+
+ $order->refresh();
+ expect($refund->status)->toBe(RefundStatus::Processed)
+ ->and($refund->provider_refund_id)->toStartWith('mock_refund_')
+ ->and($refund->amount)->toBe(5499)
+ ->and($order->financial_status)->toBe(FinancialStatus::Refunded)
+ ->and($order->status)->toBe(OrderStatus::Refunded)
+ ->and($order->payments->first()->status)->toBe(PaymentStatus::Refunded)
+ ->and($order->refundableAmount())->toBe(0);
+
+ Event::assertDispatched(OrderRefunded::class, fn (OrderRefunded $event): bool => $event->order->id === $order->id && $event->refund->id === $refund->id);
+});
+
+test('creates a partial refund', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+
+ app(RefundService::class)->create($order, $payment, 2000);
+
+ $order->refresh();
+ expect($order->financial_status)->toBe(FinancialStatus::PartiallyRefunded)
+ ->and($order->status)->toBe(OrderStatus::Paid)
+ ->and($order->refundableAmount())->toBe(3499);
+});
+
+test('rejects refund exceeding payment amount', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+
+ app(RefundService::class)->create($order, $payment, $order->total_amount + 1);
+})->throws(ValidationException::class);
+
+test('rejects a second refund exceeding the remaining refundable amount', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+ $service = app(RefundService::class);
+
+ $service->create($order, $payment, 5000);
+
+ expect($order->refresh()->refundableAmount())->toBe(499);
+
+ $service->create($order->refresh(), $payment, 500);
+})->throws(ValidationException::class);
+
+test('restocks inventory when restock flag is true', function () {
+ [$store, $variant, $order, $payment] = refundOrder(2);
+
+ expect($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8);
+
+ app(RefundService::class)->create($order, $payment, 2000, null, true);
+
+ expect($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(10);
+});
+
+test('does not restock when restock flag is false', function () {
+ [$store, $variant, $order, $payment] = refundOrder(2);
+
+ app(RefundService::class)->create($order, $payment, 2000, null, false);
+
+ expect($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8);
+});
+
+test('records refund reason', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+
+ $refund = app(RefundService::class)->create($order, $payment, 1000, 'Customer requested');
+
+ expect($refund->reason)->toBe('Customer requested');
+});
+
+test('only allows admin or owner to process refunds', function () {
+ [$store, $variant, $order, $payment] = refundOrder();
+
+ $staff = test()->createUserWithRole($store, 'staff');
+ $owner = test()->createUserWithRole($store, 'owner');
+
+ expect(Gate::forUser($staff)->inspect('createRefund', $order)->denied())->toBeTrue()
+ ->and(Gate::forUser($owner)->inspect('createRefund', $order)->allowed())->toBeTrue();
+});
diff --git a/tests/Feature/Payments/BankTransferConfirmationTest.php b/tests/Feature/Payments/BankTransferConfirmationTest.php
new file mode 100644
index 00000000..7ead876b
--- /dev/null
+++ b/tests/Feature/Payments/BankTransferConfirmationTest.php
@@ -0,0 +1,195 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ 'requires_shipping' => ! $digital,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+/**
+ * Create a bank transfer order via the full checkout flow.
+ *
+ * @return array{0: Store, 1: ProductVariant, 2: Order}
+ */
+function bankTransferOrder(bool $digital = false, int $quantity = 2): array
+{
+ [$store, $variant, $rate] = bankTransferSetup($digital);
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $digital ? null : $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'bank_transfer');
+
+ $order = $service->completeCheckout($checkout, ['payment_method' => 'bank_transfer']);
+
+ return [$store, $variant, $order];
+}
+
+test('admin can confirm bank transfer payment', function () {
+ [$store, $variant, $order] = bankTransferOrder();
+
+ expect($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2);
+
+ Event::fake([OrderPaid::class]);
+
+ app(OrderService::class)->confirmBankTransferPayment($order->refresh());
+
+ $order->refresh();
+ expect($order->financial_status)->toBe(FinancialStatus::Paid)
+ ->and($order->status)->toBe(OrderStatus::Paid)
+ ->and($order->payments->first()->status)->toBe(PaymentStatus::Captured);
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(8)
+ ->and($item->quantity_reserved)->toBe(0);
+
+ Event::assertDispatched(OrderPaid::class, fn (OrderPaid $event): bool => $event->order->id === $order->id);
+});
+
+test('cannot confirm payment for non-bank-transfer orders', function () {
+ [$store, $variant, $rate] = bankTransferSetup();
+
+ $service = app(CheckoutService::class);
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, 1);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+ $checkout = $service->selectPaymentMethod($checkout, 'credit_card');
+
+ $order = $service->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ app(OrderService::class)->confirmBankTransferPayment($order);
+})->throws(InvalidOrderTransitionException::class);
+
+test('cannot confirm already confirmed payment', function () {
+ [$store, $variant, $order] = bankTransferOrder();
+
+ $service = app(OrderService::class);
+ $service->confirmBankTransferPayment($order->refresh());
+ $service->confirmBankTransferPayment($order->refresh());
+})->throws(InvalidOrderTransitionException::class);
+
+test('auto-cancel job cancels unpaid bank transfer orders after config days', function () {
+ [$store, $variant, $order] = bankTransferOrder();
+
+ $order->update(['placed_at' => now()->subDays(8)]);
+
+ Event::fake([OrderCancelled::class]);
+
+ (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class));
+
+ $order->refresh();
+ expect($order->status)->toBe(OrderStatus::Cancelled)
+ ->and($order->financial_status)->toBe(FinancialStatus::Voided)
+ ->and($order->payments->first()->status)->toBe(PaymentStatus::Failed)
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(0);
+
+ Event::assertDispatched(OrderCancelled::class, fn (OrderCancelled $event): bool => $event->order->id === $order->id);
+});
+
+test('auto-cancel job does not cancel orders within config days', function () {
+ [$store, $variant, $order] = bankTransferOrder();
+
+ $order->update(['placed_at' => now()->subDays(2)]);
+
+ (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class));
+
+ expect($order->refresh()->status)->toBe(OrderStatus::Pending)
+ ->and($order->financial_status)->toBe(FinancialStatus::Pending)
+ ->and($variant->inventoryItem->refresh()->quantity_reserved)->toBe(2);
+});
+
+test('auto-cancel job respects the per-store bank_transfer_cancel_days setting', function () {
+ [$store, $variant, $order] = bankTransferOrder();
+
+ StoreSettings::create([
+ 'store_id' => $store->id,
+ 'settings_json' => ['bank_transfer_cancel_days' => 3],
+ ]);
+
+ $order->update(['placed_at' => now()->subDays(5)]);
+
+ (new CancelUnpaidBankTransferOrders)->handle(app(OrderService::class));
+
+ expect($order->refresh()->status)->toBe(OrderStatus::Cancelled);
+});
+
+test('auto-fulfills digital products on payment confirmation', function () {
+ [$store, $variant, $order] = bankTransferOrder(digital: true);
+
+ app(OrderService::class)->confirmBankTransferPayment($order->refresh());
+
+ $order->refresh();
+ expect($order->fulfillment_status)->toBe(FulfillmentOrderStatus::Fulfilled)
+ ->and($order->status)->toBe(OrderStatus::Fulfilled);
+
+ $fulfillment = $order->fulfillments->first();
+ expect($fulfillment)->not->toBeNull()
+ ->and($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered)
+ ->and($fulfillment->shipped_at)->not->toBeNull();
+});
diff --git a/tests/Feature/Payments/MockPaymentProviderTest.php b/tests/Feature/Payments/MockPaymentProviderTest.php
new file mode 100644
index 00000000..e6196584
--- /dev/null
+++ b/tests/Feature/Payments/MockPaymentProviderTest.php
@@ -0,0 +1,78 @@
+provider = new MockPaymentProvider;
+ $this->checkout = new Checkout;
+});
+
+test('charges credit card with success card number', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4242424242424242']);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->status)->toBe('captured')
+ ->and($result->errorCode)->toBeNull();
+});
+
+test('strips spaces from card numbers before matching', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4000 0000 0000 0002']);
+
+ expect($result->success)->toBeFalse()
+ ->and($result->errorCode)->toBe('card_declined');
+});
+
+test('declines credit card with decline card number', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4000000000000002']);
+
+ expect($result->success)->toBeFalse()
+ ->and($result->status)->toBe('failed')
+ ->and($result->errorCode)->toBe('card_declined')
+ ->and($result->errorMessage)->toBe('Your card was declined.');
+});
+
+test('returns insufficient funds for that card number', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4000000000009995']);
+
+ expect($result->success)->toBeFalse()
+ ->and($result->errorCode)->toBe('insufficient_funds')
+ ->and($result->errorMessage)->toBe('Your card has insufficient funds.');
+});
+
+test('charges any other valid-looking card number successfully', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::CreditCard, ['card_number' => '4111111111111111']);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->status)->toBe('captured');
+});
+
+test('charges PayPal successfully', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::Paypal, []);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->status)->toBe('captured');
+});
+
+test('creates pending payment for bank transfer', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::BankTransfer, []);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->status)->toBe('pending');
+});
+
+test('generates mock reference id', function () {
+ $result = $this->provider->charge($this->checkout, PaymentMethod::Paypal, []);
+
+ expect($result->referenceId)->toStartWith('mock_');
+});
+
+test('refund always succeeds with a mock refund id', function () {
+ $result = $this->provider->refund(new Payment, 5000);
+
+ expect($result->success)->toBeTrue()
+ ->and($result->providerRefundId)->toStartWith('mock_refund_')
+ ->and($result->status)->toBe('processed');
+});
diff --git a/tests/Feature/Payments/PaymentServiceTest.php b/tests/Feature/Payments/PaymentServiceTest.php
new file mode 100644
index 00000000..eb0838b2
--- /dev/null
+++ b/tests/Feature/Payments/PaymentServiceTest.php
@@ -0,0 +1,148 @@
+createStore();
+ test()->bindStore($store);
+
+ $product = Product::factory()->active()->create(['store_id' => $store->id]);
+ $variant = ProductVariant::factory()->withInventory(10)->create([
+ 'product_id' => $product->id,
+ 'price_amount' => 2500,
+ ]);
+
+ $zone = ShippingZone::factory()->create(['store_id' => $store->id, 'countries_json' => ['DE']]);
+ $rate = ShippingRate::factory()->flat(499)->create(['zone_id' => $zone->id]);
+
+ return [$store, $variant, $rate];
+}
+
+/**
+ * Drive a checkout to payment_selected with the given method.
+ */
+function paymentServiceCheckout(Store $store, ProductVariant $variant, ShippingRate $rate, string $method, int $quantity = 2): Checkout
+{
+ $service = app(CheckoutService::class);
+
+ $cart = app(CartService::class)->create($store);
+ app(CartService::class)->addLine($cart, $variant->id, $quantity);
+
+ $checkout = $service->createFromCart($cart->refresh(), 'customer@example.com');
+ $checkout = $service->setAddress($checkout, [
+ 'shipping_address' => [
+ 'first_name' => 'Jane',
+ 'last_name' => 'Doe',
+ 'address1' => '123 Main St',
+ 'city' => 'Berlin',
+ 'country' => 'DE',
+ 'country_code' => 'DE',
+ 'postal_code' => '10115',
+ ],
+ ]);
+ $checkout = $service->setShippingMethod($checkout, $rate->id);
+
+ return $service->selectPaymentMethod($checkout, $method);
+}
+
+test('resolves MockPaymentProvider from container', function () {
+ expect(app(PaymentProvider::class))->toBeInstanceOf(MockPaymentProvider::class);
+});
+
+test('processes credit card payment and creates order as paid', function () {
+ [$store, $variant, $rate] = paymentServiceSetup();
+ $checkout = paymentServiceCheckout($store, $variant, $rate, 'credit_card');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ expect($order->financial_status)->toBe(FinancialStatus::Paid);
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(8)
+ ->and($item->quantity_reserved)->toBe(0);
+
+ $payment = $order->payments->first();
+ expect($payment->status)->toBe(PaymentStatus::Captured)
+ ->and($payment->provider_payment_id)->toStartWith('mock_');
+});
+
+test('processes PayPal payment and creates order as paid', function () {
+ [$store, $variant, $rate] = paymentServiceSetup();
+ $checkout = paymentServiceCheckout($store, $variant, $rate, 'paypal');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['payment_method' => 'paypal']);
+
+ expect($order->financial_status)->toBe(FinancialStatus::Paid)
+ ->and($variant->inventoryItem->refresh()->quantity_on_hand)->toBe(8)
+ ->and($order->payments->first()->method)->toBe(PaymentMethod::Paypal);
+});
+
+test('processes bank transfer and creates order as pending', function () {
+ [$store, $variant, $rate] = paymentServiceSetup();
+ $checkout = paymentServiceCheckout($store, $variant, $rate, 'bank_transfer');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['payment_method' => 'bank_transfer']);
+
+ expect($order->financial_status)->toBe(FinancialStatus::Pending);
+
+ $item = $variant->inventoryItem->refresh();
+ expect($item->quantity_on_hand)->toBe(10)
+ ->and($item->quantity_reserved)->toBe(2);
+
+ expect($order->payments->first()->status)->toBe(PaymentStatus::Pending);
+});
+
+test('creates a payment record with correct method and provider', function () {
+ [$store, $variant, $rate] = paymentServiceSetup();
+ $checkout = paymentServiceCheckout($store, $variant, $rate, 'credit_card');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, ['card_number' => '4242424242424242']);
+
+ $payment = $order->payments->first();
+
+ expect($payment->method)->toBe(PaymentMethod::CreditCard)
+ ->and($payment->provider)->toBe('mock')
+ ->and($payment->amount)->toBe($order->total_amount)
+ ->and($payment->currency)->toBe($order->currency);
+});
+
+test('stores the provider response encrypted at rest', function () {
+ [$store, $variant, $rate] = paymentServiceSetup();
+ $checkout = paymentServiceCheckout($store, $variant, $rate, 'credit_card');
+
+ $order = app(CheckoutService::class)->completeCheckout($checkout, [
+ 'card_number' => '4242 4242 4242 4242',
+ 'card_holder' => 'Jane Doe',
+ ]);
+
+ $raw = DB::table('payments')->where('order_id', $order->id)->value('raw_json_encrypted');
+
+ expect($raw)->not->toBeNull()
+ ->not->toContain('card_last4')
+ ->not->toContain('4242');
+
+ $payload = $order->payments->first()->raw_json_encrypted;
+
+ expect($payload['card_last4'])->toBe('4242')
+ ->and($payload['card_holder'])->toBe('Jane Doe')
+ ->and($payload['status'])->toBe('captured');
+});
From 5b6613c64e153703b8660ecfd8b6ac6af4a2854d Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 16:11:29 +0200
Subject: [PATCH 08/29] Update progress tracker through Phase 5
---
specs/progress.md | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/specs/progress.md b/specs/progress.md
index 875b4ff7..50b8fd3b 100644
--- a/specs/progress.md
+++ b/specs/progress.md
@@ -25,6 +25,8 @@
| 4 | 2026-07-19 | Phase 4 | Cart/Checkout/Discount/Shipping/Tax engine: 7 models, 7 VOs, PricingEngine, DiscountService, ShippingCalculator, TaxCalculator(+providers), CartService, CheckoutService state machine, storefront cart/checkout REST API, cart drawer + cart page + checkout stepper UI, expiry/cleanup jobs. 219 tests green. Notable: intdiv tax math & merge-sum per spec 09 precedence. | pending |
+| 5 | 2026-07-19 | Phase 5 | Payments/orders/fulfillment/customers: MockPaymentProvider, PaymentService, OrderService (atomic creation, numbering, idempotency, digital auto-fulfill), RefundService, FulfillmentService (guard), 9 events + listeners, /pay + order-status APIs, checkout payment step + confirmation UI. 294 tests green. | b55fd22e |
+
## Phase Checklist
### Phase 1 — Foundation
@@ -61,11 +63,11 @@
- [x] Tests: PricingEngineTest, DiscountCalculatorTest, TaxCalculatorTest, ShippingCalculatorTest, CartVersionTest, CartServiceTest, CartApiTest, CheckoutFlowTest, CheckoutStateTest, PricingIntegrationTest, DiscountTest, ShippingTest, TaxTest
### Phase 5 — Payments / Orders / Fulfillment
-- [ ] Models: Customer, CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine
-- [ ] MockPaymentProvider, PaymentService, OrderService, RefundService, FulfillmentService, CustomerService
-- [ ] Events: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded, etc.
-- [ ] Jobs: CancelUnpaidBankTransferOrders
-- [ ] Tests: OrderCreationTest, RefundTest, FulfillmentTest, MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest
+- [x] Models: Customer, CustomerAddress, Order, OrderLine, Payment, Refund, Fulfillment, FulfillmentLine
+- [x] MockPaymentProvider, PaymentService, OrderService, RefundService, FulfillmentService, CustomerService
+- [x] Events: OrderCreated, OrderPaid, OrderFulfilled, OrderCancelled, OrderRefunded, etc.
+- [x] Jobs: CancelUnpaidBankTransferOrders
+- [x] Tests: OrderCreationTest, RefundTest, FulfillmentTest, MockPaymentProviderTest, PaymentServiceTest, BankTransferConfirmationTest
### Phase 6 — Customer Accounts + Auth UI
- [ ] Admin auth: Login, Logout, ForgotPassword, ResetPassword (Livewire)
From 9555f68a3a7422b4a18a92755364305bbde2790e Mon Sep 17 00:00:00 2001
From: Fabian Wesner
Date: Sun, 19 Jul 2026 19:54:12 +0200
Subject: [PATCH 09/29] Phase 6: Customer accounts + admin/customer auth UI
- Admin auth: Login/Logout/ForgotPassword/ResetPassword (Flux UI, rate-limited, generic errors, current_store_id on login)
- Customer auth: Login/Register/Forgot/Reset with store-scoped token repository + broker, guest cart merge on login
- Account pages: Dashboard, Orders Index/Show (timeline), Addresses CRUD
- Email verification routes
- 48 new tests: 342 passed total
---
app/Auth/CustomerPasswordBrokerManager.php | 41 +++
app/Auth/CustomerTokenRepository.php | 88 +++++
.../Auth/EmailVerificationController.php | 49 +++
.../Admin/Auth/LogoutController.php | 28 ++
.../Storefront/Account/LogoutController.php | 28 ++
app/Livewire/Admin/Auth/ForgotPassword.php | 51 +++
app/Livewire/Admin/Auth/Login.php | 88 +++++
app/Livewire/Admin/Auth/ResetPassword.php | 75 ++++
.../Concerns/ThrottlesLoginAttempts.php | 65 ++++
.../Storefront/Account/Addresses/Index.php | 218 ++++++++++++
.../Account/Auth/ForgotPassword.php | 51 +++
.../Storefront/Account/Auth/Login.php | 75 ++++
.../Storefront/Account/Auth/Register.php | 76 +++++
.../Storefront/Account/Auth/ResetPassword.php | 77 +++++
app/Livewire/Storefront/Account/Dashboard.php | 67 ++++
.../Storefront/Account/Orders/Index.php | 31 ++
.../Storefront/Account/Orders/Show.php | 41 +++
.../Concerns/MergesGuestCartOnLogin.php | 42 +++
app/Providers/AppServiceProvider.php | 30 ++
app/Services/CustomerService.php | 29 ++
bootstrap/app.php | 6 +
..._add_remember_token_to_customers_table.php | 31 ++
.../views/admin/auth/verify-email.blade.php | 27 ++
resources/views/admin/layouts/auth.blade.php | 19 ++
.../admin/auth/forgot-password.blade.php | 26 ++
.../views/livewire/admin/auth/login.blade.php | 39 +++
.../admin/auth/reset-password.blade.php | 33 ++
.../account/addresses/index.blade.php | 156 +++++++++
.../account/auth/forgot-password.blade.php | 26 ++
.../storefront/account/auth/login.blade.php | 41 +++
.../account/auth/register.blade.php | 37 ++
.../account/auth/reset-password.blade.php | 30 ++
.../storefront/account/dashboard.blade.php | 106 ++++++
.../storefront/account/orders/index.blade.php | 76 +++++
.../storefront/account/orders/show.blade.php | 220 ++++++++++++
.../components/order-status-badge.blade.php | 25 ++
.../views/storefront/layouts/app.blade.php | 2 +
routes/admin.php | 29 +-
routes/web.php | 25 ++
specs/progress.md | 10 +-
tests/Feature/Auth/AdminAuthTest.php | 251 ++++++++++++++
tests/Feature/Auth/CustomerAuthTest.php | 323 ++++++++++++++++++
.../Customers/AddressManagementTest.php | 148 ++++++++
.../Feature/Customers/CustomerAccountTest.php | 147 ++++++++
44 files changed, 3073 insertions(+), 10 deletions(-)
create mode 100644 app/Auth/CustomerPasswordBrokerManager.php
create mode 100644 app/Auth/CustomerTokenRepository.php
create mode 100644 app/Http/Controllers/Admin/Auth/EmailVerificationController.php
create mode 100644 app/Http/Controllers/Admin/Auth/LogoutController.php
create mode 100644 app/Http/Controllers/Storefront/Account/LogoutController.php
create mode 100644 app/Livewire/Admin/Auth/ForgotPassword.php
create mode 100644 app/Livewire/Admin/Auth/Login.php
create mode 100644 app/Livewire/Admin/Auth/ResetPassword.php
create mode 100644 app/Livewire/Concerns/ThrottlesLoginAttempts.php
create mode 100644 app/Livewire/Storefront/Account/Addresses/Index.php
create mode 100644 app/Livewire/Storefront/Account/Auth/ForgotPassword.php
create mode 100644 app/Livewire/Storefront/Account/Auth/Login.php
create mode 100644 app/Livewire/Storefront/Account/Auth/Register.php
create mode 100644 app/Livewire/Storefront/Account/Auth/ResetPassword.php
create mode 100644 app/Livewire/Storefront/Account/Dashboard.php
create mode 100644 app/Livewire/Storefront/Account/Orders/Index.php
create mode 100644 app/Livewire/Storefront/Account/Orders/Show.php
create mode 100644 app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php
create mode 100644 app/Services/CustomerService.php
create mode 100644 database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php
create mode 100644 resources/views/admin/auth/verify-email.blade.php
create mode 100644 resources/views/admin/layouts/auth.blade.php
create mode 100644 resources/views/livewire/admin/auth/forgot-password.blade.php
create mode 100644 resources/views/livewire/admin/auth/login.blade.php
create mode 100644 resources/views/livewire/admin/auth/reset-password.blade.php
create mode 100644 resources/views/livewire/storefront/account/addresses/index.blade.php
create mode 100644 resources/views/livewire/storefront/account/auth/forgot-password.blade.php
create mode 100644 resources/views/livewire/storefront/account/auth/login.blade.php
create mode 100644 resources/views/livewire/storefront/account/auth/register.blade.php
create mode 100644 resources/views/livewire/storefront/account/auth/reset-password.blade.php
create mode 100644 resources/views/livewire/storefront/account/dashboard.blade.php
create mode 100644 resources/views/livewire/storefront/account/orders/index.blade.php
create mode 100644 resources/views/livewire/storefront/account/orders/show.blade.php
create mode 100644 resources/views/storefront/components/order-status-badge.blade.php
create mode 100644 tests/Feature/Auth/AdminAuthTest.php
create mode 100644 tests/Feature/Auth/CustomerAuthTest.php
create mode 100644 tests/Feature/Customers/AddressManagementTest.php
create mode 100644 tests/Feature/Customers/CustomerAccountTest.php
diff --git a/app/Auth/CustomerPasswordBrokerManager.php b/app/Auth/CustomerPasswordBrokerManager.php
new file mode 100644
index 00000000..e5c8427a
--- /dev/null
+++ b/app/Auth/CustomerPasswordBrokerManager.php
@@ -0,0 +1,41 @@
+ $config
+ */
+ protected function createTokenRepository(array $config)
+ {
+ if (($config['table'] ?? null) !== 'customer_password_reset_tokens') {
+ return parent::createTokenRepository($config);
+ }
+
+ $key = $this->app['config']['app.key'];
+
+ if (str_starts_with($key, 'base64:')) {
+ $key = base64_decode(substr($key, 7));
+ }
+
+ return new CustomerTokenRepository(
+ $this->app['db']->connection($config['connection'] ?? null),
+ $this->app['hash'],
+ $config['table'],
+ $key,
+ ($config['expire'] ?? 60) * 60,
+ $config['throttle'] ?? 0,
+ );
+ }
+}
diff --git a/app/Auth/CustomerTokenRepository.php b/app/Auth/CustomerTokenRepository.php
new file mode 100644
index 00000000..15937316
--- /dev/null
+++ b/app/Auth/CustomerTokenRepository.php
@@ -0,0 +1,88 @@
+getTable()
+ ->where('store_id', $this->storeId())
+ ->where('email', $user->getEmailForPasswordReset())
+ ->first();
+
+ return $record && $this->tokenRecentlyCreated($record['created_at']);
+ }
+
+ /**
+ * Determine if a token record exists and is valid for this store.
+ *
+ * @param string $token
+ */
+ public function exists(CanResetPasswordContract $user, #[\SensitiveParameter] $token)
+ {
+ $record = (array) $this->getTable()
+ ->where('store_id', $this->storeId())
+ ->where('email', $user->getEmailForPasswordReset())
+ ->first();
+
+ return $record &&
+ ! $this->tokenExpired($record['created_at']) &&
+ $this->hasher->check($token, $record['token']);
+ }
+
+ /**
+ * Delete only the current store's tokens for the user.
+ */
+ protected function deleteExisting(CanResetPasswordContract $user)
+ {
+ return $this->getTable()
+ ->where('store_id', $this->storeId())
+ ->where('email', $user->getEmailForPasswordReset())
+ ->delete();
+ }
+
+ /**
+ * Build the record payload, including the store the reset belongs to.
+ *
+ * @param string $email
+ * @param string $token
+ * @return array
+ */
+ protected function getPayload($email, #[\SensitiveParameter] $token)
+ {
+ return [
+ 'store_id' => $this->storeId(),
+ 'email' => $email,
+ 'token' => $this->hasher->make($token),
+ 'created_at' => new Carbon,
+ ];
+ }
+
+ /**
+ * The id of the store bound to the container by ResolveStore.
+ */
+ private function storeId(): int
+ {
+ if (! app()->bound('current_store')) {
+ throw new RuntimeException('Customer password resets require a resolved store.');
+ }
+
+ return (int) app('current_store')->getKey();
+ }
+}
diff --git a/app/Http/Controllers/Admin/Auth/EmailVerificationController.php b/app/Http/Controllers/Admin/Auth/EmailVerificationController.php
new file mode 100644
index 00000000..6f4cbed8
--- /dev/null
+++ b/app/Http/Controllers/Admin/Auth/EmailVerificationController.php
@@ -0,0 +1,49 @@
+user()->hasVerifiedEmail()) {
+ return redirect('/admin');
+ }
+
+ return view('admin.auth.verify-email');
+ }
+
+ /**
+ * Mark the user's email as verified via the signed link.
+ */
+ public function verify(EmailVerificationRequest $request): RedirectResponse
+ {
+ $request->fulfill();
+
+ return redirect('/admin');
+ }
+
+ /**
+ * Resend the verification email (throttled by the route).
+ */
+ public function send(Request $request): RedirectResponse
+ {
+ if ($request->user()->hasVerifiedEmail()) {
+ return redirect('/admin');
+ }
+
+ $request->user()->sendEmailVerificationNotification();
+
+ return back()->with('status', 'verification-link-sent');
+ }
+}
diff --git a/app/Http/Controllers/Admin/Auth/LogoutController.php b/app/Http/Controllers/Admin/Auth/LogoutController.php
new file mode 100644
index 00000000..b5fc3e93
--- /dev/null
+++ b/app/Http/Controllers/Admin/Auth/LogoutController.php
@@ -0,0 +1,28 @@
+logout();
+
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect('/admin/login')->withHeaders([
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ 'Pragma' => 'no-cache',
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Storefront/Account/LogoutController.php b/app/Http/Controllers/Storefront/Account/LogoutController.php
new file mode 100644
index 00000000..18e3aef5
--- /dev/null
+++ b/app/Http/Controllers/Storefront/Account/LogoutController.php
@@ -0,0 +1,28 @@
+logout();
+
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ return redirect('/account/login')->withHeaders([
+ 'Cache-Control' => 'no-cache, no-store, must-revalidate',
+ 'Pragma' => 'no-cache',
+ ]);
+ }
+}
diff --git a/app/Livewire/Admin/Auth/ForgotPassword.php b/app/Livewire/Admin/Auth/ForgotPassword.php
new file mode 100644
index 00000000..b58ca4d1
--- /dev/null
+++ b/app/Livewire/Admin/Auth/ForgotPassword.php
@@ -0,0 +1,51 @@
+check()) {
+ $this->redirect('/admin');
+ }
+ }
+
+ /**
+ * Send a reset link through the "users" broker (spec 06 §1.1). The
+ * response is always generic so it never reveals whether the email
+ * exists. The broker throttles to one email per 60 seconds.
+ */
+ public function sendResetLink(): void
+ {
+ $this->validate([
+ 'email' => 'required|email',
+ ]);
+
+ Password::broker('users')->sendResetLink(['email' => $this->email]);
+
+ $this->linkSent = true;
+ }
+
+ /**
+ * Render the forgot-password page on the centered auth layout.
+ */
+ public function render(): View
+ {
+ return view('livewire.admin.auth.forgot-password')
+ ->layout('admin.layouts.auth')
+ ->title('Forgot password');
+ }
+}
diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php
new file mode 100644
index 00000000..35084b0b
--- /dev/null
+++ b/app/Livewire/Admin/Auth/Login.php
@@ -0,0 +1,88 @@
+check()) {
+ $this->redirect('/admin');
+ }
+ }
+
+ /**
+ * Attempt to authenticate against the web guard (spec 06 §1.1). The
+ * failure message is always generic so it never reveals which field
+ * was wrong.
+ */
+ public function login(): void
+ {
+ $this->errorMessage = null;
+
+ $credentials = $this->validate([
+ 'email' => 'required|email',
+ 'password' => 'required|string',
+ ]);
+
+ $this->ensureIsNotRateLimited();
+
+ if (! Auth::guard('web')->attempt($credentials, $this->remember)) {
+ $this->hitLoginRateLimiter();
+
+ $this->errorMessage = 'Invalid credentials.';
+
+ return;
+ }
+
+ session()->regenerate();
+
+ $user = Auth::guard('web')->user();
+ $user->forceFill(['last_login_at' => now()])->save();
+
+ $storeId = $user->stores()->value('stores.id');
+
+ if ($storeId === null) {
+ Auth::guard('web')->logout();
+ session()->invalidate();
+ session()->regenerateToken();
+
+ $this->errorMessage = 'You do not have access to any store.';
+
+ return;
+ }
+
+ session(['current_store_id' => $storeId]);
+ $this->clearLoginRateLimiter();
+
+ $this->redirect('/admin');
+ }
+
+ /**
+ * Render the login page on the centered auth layout.
+ */
+ public function render(): View
+ {
+ return view('livewire.admin.auth.login')
+ ->layout('admin.layouts.auth')
+ ->title('Log in');
+ }
+}
diff --git a/app/Livewire/Admin/Auth/ResetPassword.php b/app/Livewire/Admin/Auth/ResetPassword.php
new file mode 100644
index 00000000..2f874bc5
--- /dev/null
+++ b/app/Livewire/Admin/Auth/ResetPassword.php
@@ -0,0 +1,75 @@
+token = $token;
+ $this->email = (string) request()->query('email', '');
+ }
+
+ /**
+ * Reset the password through the "users" broker (spec 06 §1.1).
+ */
+ public function resetPassword(): void
+ {
+ $this->errorMessage = null;
+
+ $this->validate([
+ 'token' => 'required',
+ 'email' => 'required|email',
+ 'password' => 'required|min:8|confirmed',
+ ]);
+
+ $status = Password::broker('users')->reset(
+ $this->only('email', 'password', 'password_confirmation', 'token'),
+ function (User $user, string $password): void {
+ $user->forceFill([
+ 'password_hash' => $password,
+ 'remember_token' => Str::random(60),
+ ])->save();
+ },
+ );
+
+ if ($status !== Password::PASSWORD_RESET) {
+ $this->errorMessage = 'This password reset link is invalid or has expired.';
+
+ return;
+ }
+
+ session()->flash('status', 'Your password has been reset. You can now log in.');
+
+ $this->redirect(route('admin.login'));
+ }
+
+ /**
+ * Render the reset-password page on the centered auth layout.
+ */
+ public function render(): View
+ {
+ return view('livewire.admin.auth.reset-password')
+ ->layout('admin.layouts.auth')
+ ->title('Reset password');
+ }
+}
diff --git a/app/Livewire/Concerns/ThrottlesLoginAttempts.php b/app/Livewire/Concerns/ThrottlesLoginAttempts.php
new file mode 100644
index 00000000..8d490a77
--- /dev/null
+++ b/app/Livewire/Concerns/ThrottlesLoginAttempts.php
@@ -0,0 +1,65 @@
+loginLimit();
+
+ if (! RateLimiter::tooManyAttempts($this->throttleKey(), $limit->maxAttempts)) {
+ return;
+ }
+
+ $seconds = RateLimiter::availableIn($this->throttleKey());
+
+ throw new TooManyRequestsHttpException($seconds, "Too many attempts. Try again in {$seconds} seconds.");
+ }
+
+ /**
+ * Record a failed login attempt against the login limiter.
+ */
+ protected function hitLoginRateLimiter(): void
+ {
+ RateLimiter::hit($this->throttleKey(), $this->loginLimit()->decaySeconds);
+ }
+
+ /**
+ * Clear the login rate limiter after a successful login.
+ */
+ protected function clearLoginRateLimiter(): void
+ {
+ RateLimiter::clear($this->throttleKey());
+ }
+
+ /**
+ * The throttle key of the shared "login" rate limiter.
+ */
+ private function throttleKey(): string
+ {
+ return (string) $this->loginLimit()->key;
+ }
+
+ /**
+ * Resolve the named "login" limiter (5 per minute, keyed by IP).
+ */
+ private function loginLimit(): Limit
+ {
+ return (RateLimiter::limiter('login'))(request());
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Addresses/Index.php b/app/Livewire/Storefront/Account/Addresses/Index.php
new file mode 100644
index 00000000..36faf717
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Addresses/Index.php
@@ -0,0 +1,218 @@
+resetForm();
+ $this->editingId = null;
+ $this->showModal = true;
+ }
+
+ /**
+ * Open the modal pre-filled with an existing address (own addresses
+ * only, others are a 404).
+ */
+ public function edit(int $addressId): void
+ {
+ $address = $this->findAddress($addressId);
+ $data = $address->address_json ?? [];
+
+ $this->editingId = $address->id;
+ $this->label = (string) ($address->label ?? '');
+ $this->first_name = (string) ($data['first_name'] ?? '');
+ $this->last_name = (string) ($data['last_name'] ?? '');
+ $this->company = (string) ($data['company'] ?? '');
+ $this->address1 = (string) ($data['address1'] ?? '');
+ $this->address2 = (string) ($data['address2'] ?? '');
+ $this->city = (string) ($data['city'] ?? '');
+ $this->province = (string) ($data['province'] ?? '');
+ $this->province_code = (string) ($data['province_code'] ?? '');
+ $this->country = (string) ($data['country'] ?? '');
+ $this->country_code = (string) ($data['country_code'] ?? '');
+ $this->postal_code = (string) ($data['postal_code'] ?? '');
+ $this->phone = (string) ($data['phone'] ?? '');
+ $this->is_default = $address->is_default;
+ $this->showModal = true;
+ }
+
+ /**
+ * Validate and persist the address form (create or update).
+ */
+ public function save(): void
+ {
+ $validated = $this->validate($this->rules());
+
+ $customer = $this->customer();
+
+ $addressJson = [
+ 'first_name' => $validated['first_name'],
+ 'last_name' => $validated['last_name'],
+ 'company' => $validated['company'] ?: null,
+ 'address1' => $validated['address1'],
+ 'address2' => $validated['address2'] ?: null,
+ 'city' => $validated['city'],
+ 'province' => $validated['province'] ?: null,
+ 'province_code' => $validated['province_code'] ?: null,
+ 'country' => $validated['country'],
+ 'country_code' => $validated['country_code'],
+ 'postal_code' => $validated['postal_code'],
+ 'phone' => $validated['phone'] ?: null,
+ ];
+
+ if ($this->editingId !== null) {
+ $address = $this->findAddress($this->editingId);
+
+ $address->update([
+ 'label' => $validated['label'] ?: null,
+ 'address_json' => $addressJson,
+ 'is_default' => $this->is_default,
+ ]);
+ } else {
+ // The first saved address becomes the default automatically.
+ $address = $customer->addresses()->create([
+ 'label' => $validated['label'] ?: null,
+ 'address_json' => $addressJson,
+ 'is_default' => $this->is_default || ! $customer->addresses()->exists(),
+ ]);
+ }
+
+ if ($address->is_default) {
+ $customer->addresses()->whereKeyNot($address->id)->update(['is_default' => false]);
+ }
+
+ $this->showModal = false;
+ $this->resetForm();
+ }
+
+ /**
+ * Delete an address (own addresses only).
+ */
+ public function delete(int $addressId): void
+ {
+ $this->findAddress($addressId)->delete();
+ }
+
+ /**
+ * Mark an address as the default and clear the flag on the rest.
+ */
+ public function setDefault(int $addressId): void
+ {
+ $address = $this->findAddress($addressId);
+
+ $this->customer()->addresses()->whereKeyNot($address->id)->update(['is_default' => false]);
+ $address->update(['is_default' => true]);
+ }
+
+ /**
+ * Render the address book (spec 04 §10.6).
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.addresses.index', [
+ 'addresses' => $this->customer()->addresses()
+ ->orderByDesc('is_default')
+ ->orderBy('id')
+ ->get(),
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('Your addresses');
+ }
+
+ /**
+ * Validation rules for the address form (address_json fields).
+ *
+ * @return array
+ */
+ private function rules(): array
+ {
+ return [
+ 'label' => 'nullable|string|max:255',
+ 'first_name' => 'required|string|max:255',
+ 'last_name' => 'required|string|max:255',
+ 'company' => 'nullable|string|max:255',
+ 'address1' => 'required|string|max:500',
+ 'address2' => 'nullable|string|max:500',
+ 'city' => 'required|string|max:255',
+ 'province' => 'nullable|string|max:255',
+ 'province_code' => 'nullable|string|max:10',
+ 'country' => 'required|string|max:255',
+ 'country_code' => 'required|string|size:2',
+ 'postal_code' => 'required|string|max:20',
+ 'phone' => 'nullable|string|max:50',
+ 'is_default' => 'boolean',
+ ];
+ }
+
+ /**
+ * Find an address of the authenticated customer or 404.
+ */
+ private function findAddress(int $addressId): CustomerAddress
+ {
+ return $this->customer()->addresses()->findOrFail($addressId);
+ }
+
+ /**
+ * The authenticated storefront customer.
+ */
+ private function customer(): Customer
+ {
+ return Auth::guard('customer')->user();
+ }
+
+ /**
+ * Reset the form fields back to a blank state.
+ */
+ private function resetForm(): void
+ {
+ $this->resetErrorBag();
+ $this->reset([
+ 'label', 'first_name', 'last_name', 'company', 'address1', 'address2',
+ 'city', 'province', 'province_code', 'country', 'country_code',
+ 'postal_code', 'phone', 'is_default',
+ ]);
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php
new file mode 100644
index 00000000..402ff16f
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php
@@ -0,0 +1,51 @@
+check()) {
+ $this->redirect('/account');
+ }
+ }
+
+ /**
+ * Send a reset link through the store-scoped "customers" broker
+ * (spec 06 §1.2). The response is always generic so it never reveals
+ * whether the email exists in this store.
+ */
+ public function sendResetLink(): void
+ {
+ $this->validate([
+ 'email' => 'required|email',
+ ]);
+
+ Password::broker('customers')->sendResetLink(['email' => $this->email]);
+
+ $this->linkSent = true;
+ }
+
+ /**
+ * Render the forgot-password page in the storefront layout.
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.auth.forgot-password')
+ ->layout('storefront.layouts.app')
+ ->title('Forgot password');
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Auth/Login.php b/app/Livewire/Storefront/Account/Auth/Login.php
new file mode 100644
index 00000000..228e395b
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Auth/Login.php
@@ -0,0 +1,75 @@
+check()) {
+ $this->redirect('/account');
+ }
+ }
+
+ /**
+ * Attempt to authenticate against the store-scoped customer guard
+ * (spec 06 §1.2). The failure message is always generic.
+ */
+ public function login(): void
+ {
+ $this->errorMessage = null;
+
+ $credentials = $this->validate([
+ 'email' => 'required|email',
+ 'password' => 'required|string',
+ ]);
+
+ $this->ensureIsNotRateLimited();
+
+ if (! Auth::guard('customer')->attempt($credentials, $this->remember)) {
+ $this->hitLoginRateLimiter();
+
+ $this->errorMessage = 'Invalid credentials.';
+
+ return;
+ }
+
+ session()->regenerate();
+ $this->clearLoginRateLimiter();
+
+ $customer = Auth::guard('customer')->user();
+
+ $this->mergeGuestCartOnLogin($customer);
+
+ $this->redirect(session()->pull('url.intended', '/account'));
+ }
+
+ /**
+ * Render the login page in the storefront layout (spec 04 §10.1).
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.auth.login')
+ ->layout('storefront.layouts.app')
+ ->title('Log in');
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php
new file mode 100644
index 00000000..afb8e035
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Auth/Register.php
@@ -0,0 +1,76 @@
+check()) {
+ $this->redirect('/account');
+ }
+ }
+
+ /**
+ * Create the customer scoped to the current store, auto-login and
+ * merge the guest cart (spec 06 §1.2).
+ */
+ public function register(CustomerService $customers): void
+ {
+ $store = app('current_store');
+
+ $validated = $this->validate([
+ 'name' => 'required|string|max:255',
+ 'email' => [
+ 'required',
+ 'email',
+ 'max:255',
+ Rule::unique('customers', 'email')->where('store_id', $store->id),
+ ],
+ 'password' => 'required|min:8|confirmed',
+ 'marketing_opt_in' => 'boolean',
+ ]);
+
+ $customer = $customers->register($store, $validated);
+
+ Auth::guard('customer')->login($customer);
+
+ session()->regenerate();
+
+ $this->mergeGuestCartOnLogin($customer);
+
+ $this->redirect('/account');
+ }
+
+ /**
+ * Render the registration page in the storefront layout (spec 04 §10.2).
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.auth.register')
+ ->layout('storefront.layouts.app')
+ ->title('Create an account');
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php
new file mode 100644
index 00000000..2bbcae0f
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php
@@ -0,0 +1,77 @@
+token = $token;
+ $this->email = (string) request()->query('email', '');
+ }
+
+ /**
+ * Reset the password through the store-scoped "customers" broker
+ * (spec 06 §1.2). Both the token and the customer lookup are scoped
+ * to the current store.
+ */
+ public function resetPassword(): void
+ {
+ $this->errorMessage = null;
+
+ $this->validate([
+ 'token' => 'required',
+ 'email' => 'required|email',
+ 'password' => 'required|min:8|confirmed',
+ ]);
+
+ $status = Password::broker('customers')->reset(
+ $this->only('email', 'password', 'password_confirmation', 'token'),
+ function (Customer $customer, string $password): void {
+ $customer->forceFill([
+ 'password_hash' => $password,
+ 'remember_token' => Str::random(60),
+ ])->save();
+ },
+ );
+
+ if ($status !== Password::PASSWORD_RESET) {
+ $this->errorMessage = 'This password reset link is invalid or has expired.';
+
+ return;
+ }
+
+ session()->flash('status', 'Your password has been reset. You can now log in.');
+
+ $this->redirect(route('storefront.account.login'));
+ }
+
+ /**
+ * Render the reset-password page in the storefront layout.
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.auth.reset-password')
+ ->layout('storefront.layouts.app')
+ ->title('Reset password');
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php
new file mode 100644
index 00000000..02af1029
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Dashboard.php
@@ -0,0 +1,67 @@
+customer();
+
+ $this->name = (string) ($customer->name ?? '');
+ $this->marketing_opt_in = (bool) $customer->marketing_opt_in;
+ }
+
+ /**
+ * Persist the editable profile fields (name, marketing preference).
+ */
+ public function updateProfile(): void
+ {
+ $validated = $this->validate([
+ 'name' => 'required|string|max:255',
+ 'marketing_opt_in' => 'boolean',
+ ]);
+
+ $this->customer()->update($validated);
+
+ $this->profileSaved = true;
+ }
+
+ /**
+ * Render the account dashboard with the five most recent orders
+ * (spec 04 §10.3).
+ */
+ public function render(): View
+ {
+ $customer = $this->customer();
+
+ return view('livewire.storefront.account.dashboard', [
+ 'customer' => $customer,
+ 'recentOrders' => $customer->orders()->latest('placed_at')->limit(5)->get(),
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('My account');
+ }
+
+ /**
+ * The authenticated storefront customer.
+ */
+ private function customer(): Customer
+ {
+ return Auth::guard('customer')->user();
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php
new file mode 100644
index 00000000..9a42978c
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Orders/Index.php
@@ -0,0 +1,31 @@
+user()
+ ->orders()
+ ->latest('placed_at')
+ ->paginate(10);
+
+ return view('livewire.storefront.account.orders.index', [
+ 'orders' => $orders,
+ ])
+ ->layout('storefront.layouts.app')
+ ->title('Order history');
+ }
+}
diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php
new file mode 100644
index 00000000..0eed6085
--- /dev/null
+++ b/app/Livewire/Storefront/Account/Orders/Show.php
@@ -0,0 +1,41 @@
+order = Auth::guard('customer')->user()
+ ->orders()
+ ->where(fn ($query) => $query
+ ->where('order_number', $number)
+ ->orWhere('order_number', '#'.$number))
+ ->with(['lines', 'fulfillments', 'payments'])
+ ->firstOrFail();
+ }
+
+ /**
+ * Render the order detail page.
+ */
+ public function render(): View
+ {
+ return view('livewire.storefront.account.orders.show')
+ ->layout('storefront.layouts.app')
+ ->title('Order '.$this->order->order_number);
+ }
+}
diff --git a/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php b/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php
new file mode 100644
index 00000000..d892082f
--- /dev/null
+++ b/app/Livewire/Storefront/Concerns/MergesGuestCartOnLogin.php
@@ -0,0 +1,42 @@
+findForSession($store);
+
+ if ($guest === null || $guest->customer_id === $customer->id) {
+ return;
+ }
+
+ $customerCart = Cart::query()
+ ->where('store_id', $store->id)
+ ->where('customer_id', $customer->id)
+ ->where('status', CartStatus::Active)
+ ->latest('id')
+ ->first() ?? $cartService->create($store, $customer);
+
+ $cartService->mergeOnLogin($guest, $customerCart);
+
+ session(['cart_id' => $customerCart->id]);
+ }
+}
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index b8451a8a..b3c12d99 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,6 +2,7 @@
namespace App\Providers;
+use App\Auth\CustomerPasswordBrokerManager;
use App\Auth\CustomerUserProvider;
use App\Contracts\PaymentProvider;
use App\Enums\StoreUserRole;
@@ -11,11 +12,14 @@
use App\Events\OrderPaid;
use App\Events\OrderRefunded;
use App\Listeners\WriteAuditLog;
+use App\Models\Customer;
use App\Models\User;
use App\Services\Payments\MockPaymentProvider;
use App\Services\ThemeSettingsService;
use Carbon\CarbonImmutable;
+use Illuminate\Auth\Notifications\ResetPassword as ResetPasswordNotification;
use Illuminate\Cache\RateLimiting\Limit;
+use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -54,6 +58,9 @@ public function boot(): void
// Anonymous storefront components:
Blade::anonymousComponentPath(resource_path('views/storefront/components'), 'storefront');
+
+ // Anonymous admin views (layouts):
+ Blade::anonymousComponentPath(resource_path('views/admin'), 'admin');
}
/**
@@ -86,6 +93,29 @@ protected function configureAuth(): void
Auth::provider('customer', function (Application $app, array $config): CustomerUserProvider {
return new CustomerUserProvider($app['hash'], $config['model']);
});
+
+ // Store-scoped password broker for storefront customers (spec 06 §1.2).
+ // The framework's PasswordResetServiceProvider is deferred and would
+ // lazily rebind these services on first resolution, so its deferred
+ // entries are removed in favour of this binding. This must run in
+ // boot(): the deferred service map is only populated after all
+ // register() calls.
+ $this->app->removeDeferredServices(['auth.password', 'auth.password.broker']);
+ $this->app->singleton('auth.password', fn ($app): CustomerPasswordBrokerManager => new CustomerPasswordBrokerManager($app));
+ $this->app->bind('auth.password.broker', fn ($app) => $app['auth.password']->broker());
+
+ // Password reset links point at the admin form for users and at the
+ // storefront form for customers (spec 06 §1.1/§1.2).
+ ResetPasswordNotification::createUrlUsing(function (Authenticatable $notifiable, string $token): string {
+ $route = $notifiable instanceof Customer
+ ? 'storefront.password.reset'
+ : 'admin.password.reset';
+
+ return route($route, [
+ 'token' => $token,
+ 'email' => $notifiable->getEmailForPasswordReset(),
+ ]);
+ });
}
/**
diff --git a/app/Services/CustomerService.php b/app/Services/CustomerService.php
new file mode 100644
index 00000000..bb02b97f
--- /dev/null
+++ b/app/Services/CustomerService.php
@@ -0,0 +1,29 @@
+ $store->id,
+ 'name' => $data['name'],
+ 'email' => $data['email'],
+ 'password_hash' => $data['password'],
+ 'marketing_opt_in' => $data['marketing_opt_in'] ?? false,
+ ]);
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 771d76e8..cdb04e8f 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -24,6 +24,12 @@
'role.check' => CheckStoreRole::class,
'auth.customer' => CustomerAuthenticate::class,
]);
+
+ // Guests hitting the admin panel go to the admin login; everything
+ // else is storefront-facing and goes to the customer login.
+ $middleware->redirectGuestsTo(
+ fn (Illuminate\Http\Request $request): string => $request->is('admin', 'admin/*') ? '/admin/login' : '/account/login',
+ );
})
->withExceptions(function (Exceptions $exceptions): void {
// Cart optimistic-concurrency conflicts return 409 with the current
diff --git a/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php b/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php
new file mode 100644
index 00000000..ac628df6
--- /dev/null
+++ b/database/migrations/2026_07_19_000002_add_remember_token_to_customers_table.php
@@ -0,0 +1,31 @@
+text('remember_token')->nullable();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('customers', function (Blueprint $table): void {
+ $table->dropColumn('remember_token');
+ });
+ }
+};
diff --git a/resources/views/admin/auth/verify-email.blade.php b/resources/views/admin/auth/verify-email.blade.php
new file mode 100644
index 00000000..0b4b6a6c
--- /dev/null
+++ b/resources/views/admin/auth/verify-email.blade.php
@@ -0,0 +1,27 @@
+
+ Verify your email
+
+
+ Verify your email
+
+ Before getting started, please verify your email address by clicking the link we just emailed to you.
+ If you did not receive the email, you can request another one below.
+
+
+ @if (session('status') === 'verification-link-sent')
+
+ A new verification link has been sent to your email address.
+
+ @endif
+
+
+
+
+
+
diff --git a/resources/views/admin/layouts/auth.blade.php b/resources/views/admin/layouts/auth.blade.php
new file mode 100644
index 00000000..349c0d3a
--- /dev/null
+++ b/resources/views/admin/layouts/auth.blade.php
@@ -0,0 +1,19 @@
+
+
+
+
+
+ {{ $title ?? config('app.name') }}
+ @vite(['resources/css/app.css', 'resources/js/app.js'])
+ @fluxAppearance
+
+
+
+
+ {{ $slot }}
+
+
+
+ @fluxScripts
+
+
diff --git a/resources/views/livewire/admin/auth/forgot-password.blade.php b/resources/views/livewire/admin/auth/forgot-password.blade.php
new file mode 100644
index 00000000..730f3b4d
--- /dev/null
+++ b/resources/views/livewire/admin/auth/forgot-password.blade.php
@@ -0,0 +1,26 @@
+
+
+ Forgot password
+ Enter your email and we will send you a reset link.
+
+
+ @if ($linkSent)
+
+ If that email exists, we sent a reset link.
+
+ @endif
+
+
+
+
+ Back to log in
+
+
diff --git a/resources/views/livewire/admin/auth/login.blade.php b/resources/views/livewire/admin/auth/login.blade.php
new file mode 100644
index 00000000..50eddb61
--- /dev/null
+++ b/resources/views/livewire/admin/auth/login.blade.php
@@ -0,0 +1,39 @@
+
+
+ Log in
+ Sign in to your admin account
+
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($errorMessage)
+
+ {{ $errorMessage }}
+
+ @endif
+
+
+
diff --git a/resources/views/livewire/admin/auth/reset-password.blade.php b/resources/views/livewire/admin/auth/reset-password.blade.php
new file mode 100644
index 00000000..ef054180
--- /dev/null
+++ b/resources/views/livewire/admin/auth/reset-password.blade.php
@@ -0,0 +1,33 @@
+
+
+ Reset password
+ Choose a new password for your account.
+
+
+ @if ($errorMessage)
+
+ {{ $errorMessage }}
+
+ @endif
+
+
+
diff --git a/resources/views/livewire/storefront/account/addresses/index.blade.php b/resources/views/livewire/storefront/account/addresses/index.blade.php
new file mode 100644
index 00000000..bd46f9fe
--- /dev/null
+++ b/resources/views/livewire/storefront/account/addresses/index.blade.php
@@ -0,0 +1,156 @@
+
+
+
Your addresses
+ Add new address
+
+
+ @if ($addresses->isEmpty())
+
You have no saved addresses yet.
+ @else
+
+ @foreach ($addresses as $address)
+ @php $data = $address->address_json ?? []; @endphp
+
+
+ @if ($address->label)
+
{{ $address->label }}
+ @else
+
+ @endif
+ @if ($address->is_default)
+
Default
+ @endif
+
+
+
+ {{ trim(($data['first_name'] ?? '').' '.($data['last_name'] ?? '')) }}
+ @if (! empty($data['company'])){{ $data['company'] }} @endif
+ {{ $data['address1'] ?? '' }}
+ @if (! empty($data['address2'])){{ $data['address2'] }} @endif
+ {{ $data['city'] ?? '' }}@if (! empty($data['province_code'] ?? $data['province'] ?? null)), {{ $data['province_code'] ?? $data['province'] }}@endif {{ $data['postal_code'] ?? '' }}
+ {{ $data['country'] ?? $data['country_code'] ?? '' }}
+ @if (! empty($data['phone'])){{ $data['phone'] }} @endif
+
+
+
+
+ Edit
+
+
+ Delete
+
+ @if (! $address->is_default)
+
+ Set as default
+
+ @endif
+
+
+ @endforeach
+
+ @endif
+
+ {{-- Add/edit modal (spec 04 §10.6) --}}
+
+
+
{{ $editingId === null ? 'Add address' : 'Edit address' }}
+
+
+
+
+
diff --git a/resources/views/livewire/storefront/account/auth/forgot-password.blade.php b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php
new file mode 100644
index 00000000..86aad471
--- /dev/null
+++ b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php
@@ -0,0 +1,26 @@
+
+
Forgot password
+
+ Enter your email and we will send you a reset link.
+
+
+ @if ($linkSent)
+
+ If that email exists, we sent a reset link.
+
+ @endif
+
+
+
+
+ Back to log in
+
+
diff --git a/resources/views/livewire/storefront/account/auth/login.blade.php b/resources/views/livewire/storefront/account/auth/login.blade.php
new file mode 100644
index 00000000..3f368540
--- /dev/null
+++ b/resources/views/livewire/storefront/account/auth/login.blade.php
@@ -0,0 +1,41 @@
+
+
Log in to your account
+
+ @if (session('status'))
+
+ {{ session('status') }}
+
+ @endif
+
+ @if ($errorMessage)
+
+ {{ $errorMessage }}
+
+ @endif
+
+
+
+
+ Don't have an account?
+ Create one
+
+
diff --git a/resources/views/livewire/storefront/account/auth/register.blade.php b/resources/views/livewire/storefront/account/auth/register.blade.php
new file mode 100644
index 00000000..0c1831be
--- /dev/null
+++ b/resources/views/livewire/storefront/account/auth/register.blade.php
@@ -0,0 +1,37 @@
+
+
Create an account
+
+
+
+
+ Already have an account?
+ Log in
+
+
diff --git a/resources/views/livewire/storefront/account/auth/reset-password.blade.php b/resources/views/livewire/storefront/account/auth/reset-password.blade.php
new file mode 100644
index 00000000..71db17a7
--- /dev/null
+++ b/resources/views/livewire/storefront/account/auth/reset-password.blade.php
@@ -0,0 +1,30 @@
+
+
Reset password
+
+ @if ($errorMessage)
+
+ {{ $errorMessage }}
+
+ @endif
+
+
+
diff --git a/resources/views/livewire/storefront/account/dashboard.blade.php b/resources/views/livewire/storefront/account/dashboard.blade.php
new file mode 100644
index 00000000..4c6d16ca
--- /dev/null
+++ b/resources/views/livewire/storefront/account/dashboard.blade.php
@@ -0,0 +1,106 @@
+
+
Welcome back, {{ $customer->name }}!
+
{{ $customer->email }}
+
+ {{-- Quick links (spec 04 §10.3) --}}
+
+
+ {{-- Recent orders --}}
+
+
+
+ @if ($recentOrders->isEmpty())
+
You haven't placed any orders yet.
+ @else
+
+
+
+
+ Order
+ Date
+ Status
+ Total
+ View
+
+
+
+ @foreach ($recentOrders as $order)
+
+ {{ $order->order_number }}
+ {{ $order->placed_at?->format('M j, Y') }}
+
+ {{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+ View
+
+
+
+ @endforeach
+
+
+
+ @endif
+
+
+ {{-- Profile settings --}}
+
+
Profile
+
+ @if ($profileSaved)
+
+ Your profile has been updated.
+
+ @endif
+
+
+
+
diff --git a/resources/views/livewire/storefront/account/orders/index.blade.php b/resources/views/livewire/storefront/account/orders/index.blade.php
new file mode 100644
index 00000000..66ae6d03
--- /dev/null
+++ b/resources/views/livewire/storefront/account/orders/index.blade.php
@@ -0,0 +1,76 @@
+
+
+
+
Order history
+
+ @if ($orders->isEmpty())
+
You haven't placed any orders yet.
+ @else
+ {{-- Table on desktop (spec 04 §10.4) --}}
+
+
+
+ Order
+ Date
+ Status
+ Total
+ View
+
+
+
+ @foreach ($orders as $order)
+
+
+
+ {{ $order->order_number }}
+
+
+ {{ $order->placed_at?->format('M j, Y') }}
+
+
+
+ @if ($order->financial_status !== $order->status)
+
+ @endif
+
+
+
+ {{ \App\Support\Money::format($order->total_amount, $order->currency) }}
+
+
+ View
+
+
+
+ @endforeach
+
+
+
+ {{-- Cards on mobile (spec 04 §10.4) --}}
+
+
+
+ {{ $orders->links() }}
+
+ @endif
+
diff --git a/resources/views/livewire/storefront/account/orders/show.blade.php b/resources/views/livewire/storefront/account/orders/show.blade.php
new file mode 100644
index 00000000..fcfdd7d6
--- /dev/null
+++ b/resources/views/livewire/storefront/account/orders/show.blade.php
@@ -0,0 +1,220 @@
+@php
+ use App\Enums\FinancialStatus;
+ use App\Enums\FulfillmentShipmentStatus;
+ use App\Enums\PaymentMethod;
+ use App\Enums\PaymentStatus;
+ use App\Support\Money;
+
+ $paymentMethodLabels = [
+ PaymentMethod::CreditCard->value => 'Credit card',
+ PaymentMethod::Paypal->value => 'PayPal',
+ PaymentMethod::BankTransfer->value => 'Bank transfer',
+ ];
+
+ $formatAddress = function (?array $address): array {
+ if (empty($address)) {
+ return [];
+ }
+
+ return array_values(array_filter([
+ trim(($address['first_name'] ?? '').' '.($address['last_name'] ?? '')),
+ $address['company'] ?? null,
+ $address['address1'] ?? null,
+ $address['address2'] ?? null,
+ trim(($address['city'] ?? '').' '.($address['province_code'] ?? $address['province'] ?? '').' '.($address['postal_code'] ?? '')),
+ $address['country'] ?? $address['country_code'] ?? null,
+ $address['phone'] ?? null,
+ ]));
+ };
+
+ $shippingAddress = $formatAddress($order->shipping_address_json);
+ $billingAddress = $formatAddress($order->billing_address_json);
+
+ $payment = $order->payments->firstWhere('status', PaymentStatus::Captured) ?? $order->payments->first();
+
+ $isPaid = in_array($order->financial_status, [FinancialStatus::Paid, FinancialStatus::PartiallyRefunded, FinancialStatus::Refunded], true);
+ $shippedAt = $order->fulfillments->whereNotNull('shipped_at')->min('shipped_at');
+ $deliveredAt = $order->fulfillments->where('status', FulfillmentShipmentStatus::Delivered)->min('created_at');
+
+ $timeline = [
+ ['label' => 'Placed', 'done' => $order->placed_at !== null, 'at' => $order->placed_at],
+ ['label' => 'Paid', 'done' => $isPaid, 'at' => $payment?->created_at],
+ ['label' => 'Shipped', 'done' => $shippedAt !== null, 'at' => $shippedAt],
+ ['label' => 'Delivered', 'done' => $deliveredAt !== null, 'at' => $deliveredAt],
+ ];
+@endphp
+
+
+
+
+ {{-- Header (spec 04 §10.5) --}}
+
+
Order {{ $order->order_number }}
+
+
+
+
+
+
Placed on {{ $order->placed_at?->format('F j, Y') }}
+
+ {{-- Timeline: placed -> paid -> shipped -> delivered --}}
+
+ @foreach ($timeline as $index => $step)
+
+ @if ($index > 0)
+
+ @endif
+
+ @if ($step['done'])
+
+
+
+ @else
+
+
+
+ @endif
+
+ {{ $step['label'] }}
+ @if ($step['done'] && $step['at'] !== null)
+ {{ \Illuminate\Support\Carbon::parse($step['at'])->format('M j, Y') }}
+ @endif
+
+
+
+ @endforeach
+
+
+ {{-- Items --}}
+
+
Items
+
+
+ @foreach ($order->lines as $line)
+
+
+
+ @php $image = $line->variant?->product?->media->first(); @endphp
+ @if ($image !== null)
+
+ @else
+
+ @endif
+
+
{{ $line->title_snapshot }}
+ @if ($line->sku_snapshot)
+
SKU: {{ $line->sku_snapshot }}
+ @endif
+
+
+
+ ×{{ $line->quantity }}
+ {{ Money::format($line->total_amount, $order->currency) }}
+
+ @endforeach
+
+
+
+
+ {{-- Info grid: shipping / billing / payment --}}
+
+
+
Shipping address
+
+ @forelse ($shippingAddress as $line)
+ {{ $line }}
+ @empty
+ No shipping address
+ @endforelse
+
+
+
+
Billing address
+
+ @if ($billingAddress === $shippingAddress && $billingAddress !== [])
+ Same as shipping
+ @else
+ @forelse ($billingAddress as $line)
+ {{ $line }}
+ @empty
+ No billing address
+ @endforelse
+ @endif
+
+
+
+
Payment
+
+ {{ $paymentMethodLabels[$order->payment_method->value] ?? $order->payment_method->value }}
+ @if ($payment !== null)
+
+ {{ Money::format($payment->amount, $payment->currency) }} · {{ str_replace('_', ' ', $payment->status->value) }}
+
+ @endif
+
+
+
+
+ {{-- Totals --}}
+
+
+
Subtotal
+ {{ Money::format($order->subtotal_amount, $order->currency) }}
+
+
+
Shipping
+ {{ Money::format($order->shipping_amount, $order->currency) }}
+
+
+
Tax
+ {{ Money::format($order->tax_amount, $order->currency) }}
+
+
+
Discount
+ -{{ Money::format($order->discount_amount, $order->currency) }}
+
+
+
Total
+ {{ Money::format($order->total_amount, $order->currency) }}
+
+
+
+ {{-- Fulfillment / tracking (only when fulfillments exist) --}}
+ @if ($order->fulfillments->isNotEmpty())
+
+
Fulfillment
+
+ @foreach ($order->fulfillments as $fulfillment)
+
+
+ {{ $fulfillment->status->value }}
+ @if ($fulfillment->tracking_company)
+ via {{ $fulfillment->tracking_company }}
+ @endif
+ @if ($fulfillment->tracking_number)
+ · {{ $fulfillment->tracking_number }}
+ @endif
+
+ @if ($fulfillment->tracking_url)
+
+ Track shipment
+
+
+
+
+ @endif
+
+ @endforeach
+
+
+ @endif
+
diff --git a/resources/views/storefront/components/order-status-badge.blade.php b/resources/views/storefront/components/order-status-badge.blade.php
new file mode 100644
index 00000000..6ee32069
--- /dev/null
+++ b/resources/views/storefront/components/order-status-badge.blade.php
@@ -0,0 +1,25 @@
+{{--
+ Order status badge (spec 04 §10.4: pending yellow, paid green,
+ fulfilled blue, cancelled gray, refunded red).
+
+ Props:
+ - status: string (required) — order status value (pending|paid|fulfilled|cancelled|refunded)
+--}}
+@props([
+ 'status',
+])
+
+@php
+ $variantClasses = [
+ 'pending' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950 dark:text-yellow-300',
+ 'paid' => 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400',
+ 'fulfilled' => 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400',
+ 'cancelled' => 'bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300',
+ 'refunded' => 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400',
+ ];
+ $classes = $variantClasses[$status] ?? $variantClasses['pending'];
+@endphp
+
+class('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold capitalize '.$classes) }}>
+ {{ str_replace('_', ' ', $status) }}
+
diff --git a/resources/views/storefront/layouts/app.blade.php b/resources/views/storefront/layouts/app.blade.php
index 62f847db..b4a59442 100644
--- a/resources/views/storefront/layouts/app.blade.php
+++ b/resources/views/storefront/layouts/app.blade.php
@@ -274,5 +274,7 @@ class="fixed inset-y-0 right-0 z-50 flex w-full flex-col bg-white shadow-xl sm:w
+
+ @fluxScripts