diff --git a/.agents/skills/developing-with-fortify/SKILL.md b/.agents/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /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` | \ No newline at end of file diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /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, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, 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 \ No newline at end of file diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +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, prioritized by impact. Each rule 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 — 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. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file 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..920714a1 --- /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) + ); +} +``` \ No newline at end of file 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..6112a635 --- /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 execute(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); + } +} +``` \ No newline at end of file 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..c6f8aaf1 --- /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. \ No newline at end of file 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..e65146dc --- /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']], +``` \ No newline at end of file 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..14f683d3 --- /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 {} +``` \ No newline at end of file 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..193155d6 --- /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')); +``` \ No newline at end of file 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..8fb71937 --- /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 +``` \ No newline at end of file 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..09cd66a0 --- /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. \ No newline at end of file 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..bb8e7a38 --- /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]; + } +} +``` \ No newline at end of file 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..47fcf324 --- /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. \ No newline at end of file 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..fd37ddb9 --- /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(), +]); +``` \ No newline at end of file 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..2435d9cc --- /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. \ No newline at end of file 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..de25aa39 --- /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']); +``` \ No newline at end of file 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..f7aa548b --- /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, + ], + ], +], +``` \ No newline at end of file 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..977d136e --- /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'); +} +``` \ No newline at end of file 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..dfaefa26 --- /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'); + }); +``` \ No newline at end of file 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..909ff91a --- /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 +
+ @csrf + +
+``` + +## 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', + ]; + } +} +``` \ No newline at end of file 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..67af9891 --- /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 + +``` + +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()) +``` \ No newline at end of file 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..287b083b --- /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(); +``` \ No newline at end of file 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..a20202ff --- /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.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.agents/skills/livewire-development/SKILL.md b/.agents/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..c009dae6 --- /dev/null +++ b/.agents/skills/livewire-development/SKILL.md @@ -0,0 +1,156 @@ +--- +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 (default in v4) + +php artisan make:livewire create-post + +# Multi-file component + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +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 + +Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) | +| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) | + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`. + +### Single-File Component Example + + +```php +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) \ No newline at end of file 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..d6a44170 --- /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 \ No newline at end of file diff --git a/.agents/skills/pest-testing/SKILL.md b/.agents/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.agents/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +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}`. + +### 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 \ No newline at end of file diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /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 +
+
Item 1
+
Item 2
+
+``` + +## 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 \ No newline at end of file diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 101f3c3e..cbd22839 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,17 +1,17 @@ { + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)" + ] + }, "enableAllProjectMcpServers": true, "enabledMcpjsonServers": [ "laravel-boost", "herd" ], "sandbox": { - "enabled": true, - "autoAllowBashIfSandboxed": true - }, - "permissions": { - "allow": [ - "Bash(git add:*)", - "Bash(git commit:*)" - ] + "enabled": false, + "autoAllowBashIfSandboxed": false } } diff --git a/.github/skills/developing-with-fortify/SKILL.md b/.github/skills/developing-with-fortify/SKILL.md new file mode 100644 index 00000000..2ff71a4b --- /dev/null +++ b/.github/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` | \ No newline at end of file diff --git a/.github/skills/fluxui-development/SKILL.md b/.github/skills/fluxui-development/SKILL.md new file mode 100644 index 00000000..4b5aabb1 --- /dev/null +++ b/.github/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, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, 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 \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/SKILL.md b/.github/skills/laravel-best-practices/SKILL.md new file mode 100644 index 00000000..aca32c9c --- /dev/null +++ b/.github/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,190 @@ +--- +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, prioritized by impact. Each rule 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 — 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. + +## Quick Reference + +### 1. Database Performance → `rules/db-performance.md` + +- Eager load with `with()` to prevent N+1 queries +- Enable `Model::preventLazyLoading()` in development +- Select only needed columns, avoid `SELECT *` +- `chunk()` / `chunkById()` for large datasets +- Index columns used in `WHERE`, `ORDER BY`, `JOIN` +- `withCount()` instead of loading relations to count +- `cursor()` for memory-efficient read-only iteration +- Never query in Blade templates + +### 2. Advanced Query Patterns → `rules/advanced-queries.md` + +- `addSelect()` subqueries over eager-loading entire has-many for a single value +- Dynamic relationships via subquery FK + `belongsTo` +- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries +- `setRelation()` to prevent circular N+1 queries +- `whereIn` + `pluck()` over `whereHas` for better index usage +- Two simple queries can beat one complex query +- Compound indexes matching `orderBy` column order +- Correlated subqueries in `orderBy` for has-many sorting (avoid joins) + +### 3. Security → `rules/security.md` + +- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates +- No raw SQL with user input — use Eloquent or query builder +- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes +- Validate MIME type, extension, and size for file uploads +- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields + +### 4. Caching → `rules/caching.md` + +- `Cache::remember()` over manual get/put +- `Cache::flexible()` for stale-while-revalidate on high-traffic data +- `Cache::memo()` to avoid redundant cache hits within a request +- Cache tags to invalidate related groups +- `Cache::add()` for atomic conditional writes +- `once()` to memoize per-request or per-object lifetime +- `Cache::lock()` / `lockForUpdate()` for race conditions +- Failover cache stores in production + +### 5. Eloquent Patterns → `rules/eloquent.md` + +- Correct relationship types with return type hints +- Local scopes for reusable query constraints +- Global scopes sparingly — document their existence +- Attribute casts in the `casts()` method +- Cast date columns, use Carbon instances in templates +- `whereBelongsTo($model)` for cleaner queries +- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries + +### 6. Validation & Forms → `rules/validation.md` + +- Form Request classes, not inline validation +- Array notation `['required', 'email']` for new code; follow existing convention +- `$request->validated()` only — never `$request->all()` +- `Rule::when()` for conditional validation +- `after()` instead of `withValidator()` + +### 7. Configuration → `rules/config.md` + +- `env()` only inside config files +- `App::environment()` or `app()->isProduction()` +- Config, lang files, and constants over hardcoded text + +### 8. Testing Patterns → `rules/testing.md` + +- `LazilyRefreshDatabase` over `RefreshDatabase` for speed +- `assertModelExists()` over raw `assertDatabaseHas()` +- Factory states and sequences over manual overrides +- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before +- `recycle()` to share relationship instances across factories + +### 9. Queue & Job Patterns → `rules/queue-jobs.md` + +- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]` +- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release +- Always implement `failed()`; with `retryUntil()`, set `$tries = 0` +- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs +- Horizon for complex multi-queue scenarios + +### 10. Routing & Controllers → `rules/routing.md` + +- Implicit route model binding +- Scoped bindings for nested resources +- `Route::resource()` or `apiResource()` +- Methods under 10 lines — extract to actions/services +- Type-hint Form Requests for auto-validation + +### 11. HTTP Client → `rules/http-client.md` + +- Explicit `timeout` and `connectTimeout` on every request +- `retry()` with exponential backoff for external APIs +- Check response status or use `throw()` +- `Http::pool()` for concurrent independent requests +- `Http::fake()` and `preventStrayRequests()` in tests + +### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md` + +- Event discovery over manual registration; `event:cache` in production +- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions +- Queue notifications and mailables with `ShouldQueue` +- On-demand notifications for non-user recipients +- `HasLocalePreference` on notifiable models +- `assertQueued()` not `assertSent()` for queued mailables +- Markdown mailables for transactional emails + +### 13. Error Handling → `rules/error-handling.md` + +- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern +- `ShouldntReport` for exceptions that should never log +- Throttle high-volume exceptions to protect log sinks +- `dontReportDuplicates()` for multi-catch scenarios +- Force JSON rendering for API routes +- Structured context via `context()` on exception classes + +### 14. Task Scheduling → `rules/scheduling.md` + +- `withoutOverlapping()` on variable-duration tasks +- `onOneServer()` on multi-server deployments +- `runInBackground()` for concurrent long tasks +- `environments()` to restrict to appropriate environments +- `takeUntilTimeout()` for time-bounded processing +- Schedule groups for shared configuration + +### 15. Architecture → `rules/architecture.md` + +- Single-purpose Action classes; dependency injection over `app()` helper +- Prefer official Laravel packages and follow conventions, don't override defaults +- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety +- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution + +### 16. Migrations → `rules/migrations.md` + +- Generate migrations with `php artisan make:migration` +- `constrained()` for foreign keys +- Never modify migrations that have run in production +- Add indexes in the migration, not as an afterthought +- Mirror column defaults in model `$attributes` +- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes +- One concern per migration — never mix DDL and DML + +### 17. Collections → `rules/collections.md` + +- Higher-order messages for simple collection operations +- `cursor()` vs. `lazy()` — choose based on relationship needs +- `lazyById()` when updating records while iterating +- `toQuery()` for bulk operations on collections + +### 18. Blade & Views → `rules/blade-views.md` + +- `$attributes->merge()` in component templates +- Blade components over `@include`; `@pushOnce` for per-component scripts +- View Composers for shared view data +- `@aware` for deeply nested component props + +### 19. Conventions & Style → `rules/style.md` + +- Follow Laravel naming conventions for all entities +- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions +- No JS/CSS in Blade, no HTML in PHP classes +- Code should be readable; comments only for config files + +## How to Apply + +Always use a sub-agent to read rule files and explore this skill's content. + +1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10) +2. Check sibling files for existing patterns — follow those first per Consistency First +3. Verify API syntax with `search-docs` for the installed Laravel version \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/advanced-queries.md b/.github/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 00000000..920714a1 --- /dev/null +++ b/.github/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) + ); +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/architecture.md b/.github/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 00000000..6112a635 --- /dev/null +++ b/.github/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 execute(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); + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/blade-views.md b/.github/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 00000000..c6f8aaf1 --- /dev/null +++ b/.github/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. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/caching.md b/.github/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 00000000..e65146dc --- /dev/null +++ b/.github/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']], +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/collections.md b/.github/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 00000000..14f683d3 --- /dev/null +++ b/.github/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 {} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/config.md b/.github/skills/laravel-best-practices/rules/config.md new file mode 100644 index 00000000..193155d6 --- /dev/null +++ b/.github/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')); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/db-performance.md b/.github/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 00000000..8fb71937 --- /dev/null +++ b/.github/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 +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/eloquent.md b/.github/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 00000000..09cd66a0 --- /dev/null +++ b/.github/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. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/error-handling.md b/.github/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 00000000..bb8e7a38 --- /dev/null +++ b/.github/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]; + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/events-notifications.md b/.github/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 00000000..47fcf324 --- /dev/null +++ b/.github/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. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/http-client.md b/.github/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 00000000..fd37ddb9 --- /dev/null +++ b/.github/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(), +]); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/mail.md b/.github/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 00000000..2435d9cc --- /dev/null +++ b/.github/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. \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/migrations.md b/.github/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 00000000..de25aa39 --- /dev/null +++ b/.github/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']); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/queue-jobs.md b/.github/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 00000000..f7aa548b --- /dev/null +++ b/.github/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, + ], + ], +], +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/routing.md b/.github/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 00000000..977d136e --- /dev/null +++ b/.github/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'); +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/scheduling.md b/.github/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 00000000..dfaefa26 --- /dev/null +++ b/.github/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'); + }); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/security.md b/.github/skills/laravel-best-practices/rules/security.md new file mode 100644 index 00000000..909ff91a --- /dev/null +++ b/.github/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 +
+ @csrf + +
+``` + +## 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', + ]; + } +} +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/style.md b/.github/skills/laravel-best-practices/rules/style.md new file mode 100644 index 00000000..67af9891 --- /dev/null +++ b/.github/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 + +``` + +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()) +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/testing.md b/.github/skills/laravel-best-practices/rules/testing.md new file mode 100644 index 00000000..287b083b --- /dev/null +++ b/.github/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(); +``` \ No newline at end of file diff --git a/.github/skills/laravel-best-practices/rules/validation.md b/.github/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 00000000..a20202ff --- /dev/null +++ b/.github/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.'); + } + }, + ]; +} +``` \ No newline at end of file diff --git a/.github/skills/livewire-development/SKILL.md b/.github/skills/livewire-development/SKILL.md new file mode 100644 index 00000000..c009dae6 --- /dev/null +++ b/.github/skills/livewire-development/SKILL.md @@ -0,0 +1,156 @@ +--- +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 (default in v4) + +php artisan make:livewire create-post + +# Multi-file component + +php artisan make:livewire create-post --mfc + +# Class-based component (v3 style) + +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 + +Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention. + +### Component Format Reference + +| Format | Flag | Class Path | View Path | +|--------|------|------------|-----------| +| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) | +| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` | +| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) | + +Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`. + +### Single-File Component Example + + +```php +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) \ No newline at end of file diff --git a/.github/skills/livewire-development/reference/javascript-hooks.md b/.github/skills/livewire-development/reference/javascript-hooks.md new file mode 100644 index 00000000..d6a44170 --- /dev/null +++ b/.github/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 \ No newline at end of file diff --git a/.github/skills/pest-testing/SKILL.md b/.github/skills/pest-testing/SKILL.md new file mode 100644 index 00000000..323d4723 --- /dev/null +++ b/.github/skills/pest-testing/SKILL.md @@ -0,0 +1,159 @@ +--- +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}`. + +### 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 \ No newline at end of file diff --git a/.github/skills/tailwindcss-development/SKILL.md b/.github/skills/tailwindcss-development/SKILL.md new file mode 100644 index 00000000..7c8e295e --- /dev/null +++ b/.github/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 +
+
Item 1
+
Item 2
+
+``` + +## 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 \ No newline at end of file diff --git a/.gitignore b/.gitignore index c7cf1fa6..e4275dd3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ yarn-error.log /.nova /.vscode /.zed +tests/Browser/Screenshots/ +.playwright-mcp/ +*.png diff --git a/AGENTS.md b/AGENTS.md index 296f2af0..bcc30226 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,3 +23,225 @@ 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. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +- `developing-with-fortify` — 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-best-practices` — 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. +- `fluxui-development` — 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. +- `livewire-development` — 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. +- `pest-testing` — 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. +- `tailwindcss-development` — 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. + +## 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. +- To check environment variables, read the `.env` file directly. + +## 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. + +=== 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`. + +## Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== 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}`. +- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`. +- Do NOT delete tests without approval. + + diff --git a/README.md b/README.md new file mode 100644 index 00000000..9da38b84 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +Your mission is to implement an entire shop system based on the specifications im specs/*. Distribute work to sub-agents. Keep the context of the main agent clean. You must do in one go without stopping. You must use 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 with Playwright in Chrome and showcase all features (customer- and admin-side) to me. In case bugs appear, you must fix them all and restart the review meeting. Shop is running at http://shop.test/. + +Don't re-use any existing implementation in another branch. Build it from scratch. + +Additional rules: +- You are not allowed to decide against team mode +- You have to ensure 100% of the required tests are verified and PASS +- You have to ensure maximum code quality +- You have to finish in one go, without stopping and asking me to continue. If there are questions on the way, you have to respond to them on your own. diff --git a/app/Auth/CustomerTokenRepository.php b/app/Auth/CustomerTokenRepository.php new file mode 100644 index 00000000..be2373e4 --- /dev/null +++ b/app/Auth/CustomerTokenRepository.php @@ -0,0 +1,103 @@ +deleteExisting($user); + + $token = $this->createNewToken(); + + $this->getTable()->insert($this->getPayload( + $user->getEmailForPasswordReset(), + $token, + $this->storeIdForUser($user), + )); + + return $token; + } + + protected function deleteExisting(CanResetPasswordContract $user): int + { + return $this->getTable() + ->where('email', $user->getEmailForPasswordReset()) + ->where('store_id', $this->storeIdForUser($user)) + ->delete(); + } + + /** + * @return array + */ + protected function getPayload($email, #[\SensitiveParameter] $token, ?int $storeId = null): array + { + return [ + 'email' => $email, + 'store_id' => $storeId ?? $this->resolveCurrentStoreId(), + 'token' => $this->hasher->make($token), + 'created_at' => new Carbon, + ]; + } + + public function exists(CanResetPasswordContract $user, #[\SensitiveParameter] $token) + { + $record = (array) $this->getTable() + ->where('email', $user->getEmailForPasswordReset()) + ->where('store_id', $this->storeIdForUser($user)) + ->first(); + + return $record !== [] + && ! $this->tokenExpired((string) $record['created_at']) + && $this->hasher->check($token, (string) $record['token']); + } + + public function recentlyCreatedToken(CanResetPasswordContract $user) + { + $record = (array) $this->getTable() + ->where('email', $user->getEmailForPasswordReset()) + ->where('store_id', $this->storeIdForUser($user)) + ->first(); + + return $record !== [] && $this->tokenRecentlyCreated((string) $record['created_at']); + } + + public function delete(CanResetPasswordContract $user) + { + $this->deleteExisting($user); + } + + protected function storeIdForUser(CanResetPasswordContract $user): int + { + if ($user instanceof Customer && $user->store_id) { + return (int) $user->store_id; + } + + return $this->resolveCurrentStoreId(); + } + + protected function resolveCurrentStoreId(): int + { + if (app()->bound('current_store')) { + $store = app('current_store'); + + if ($store instanceof Store) { + return (int) $store->getKey(); + } + } + + return 0; + } + + protected function getTable(): Builder + { + return $this->connection->table($this->table); + } +} diff --git a/app/Auth/CustomerUserProvider.php b/app/Auth/CustomerUserProvider.php new file mode 100644 index 00000000..95c8904a --- /dev/null +++ b/app/Auth/CustomerUserProvider.php @@ -0,0 +1,57 @@ + $credentials + */ + public function retrieveByCredentials(array $credentials): ?Authenticatable + { + if (empty($credentials)) { + return null; + } + + $query = $this->newModelQuery(); + $this->applyStoreScope($query); + + foreach ($credentials as $key => $value) { + if (str_contains($key, 'password')) { + continue; + } + + if (is_array($value) || $value instanceof \Illuminate\Contracts\Support\Arrayable) { + $query->whereIn($key, $value); + } elseif ($value instanceof \Closure) { + $value($query); + } else { + $query->where($key, $value); + } + } + + return $query->first(); + } + + /** + * @param Builder $query + */ + protected function applyStoreScope(Builder $query): void + { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if ($store instanceof Store) { + $query->where('store_id', $store->getKey()); + } + } +} diff --git a/app/Console/Commands/RollupAnalyticsDaily.php b/app/Console/Commands/RollupAnalyticsDaily.php new file mode 100644 index 00000000..2c513556 --- /dev/null +++ b/app/Console/Commands/RollupAnalyticsDaily.php @@ -0,0 +1,33 @@ +option('date') !== null + ? CarbonImmutable::parse((string) $this->option('date')) + : CarbonImmutable::yesterday(); + + $date = $date->startOfDay(); + + $stores = Store::query()->get(); + + foreach ($stores as $store) { + $metrics->rollupDay($store, $date); + $this->info(sprintf('Rolled up store %d for %s', $store->getKey(), $date->toDateString())); + } + + return self::SUCCESS; + } +} diff --git a/app/Contracts/PaymentProvider.php b/app/Contracts/PaymentProvider.php new file mode 100644 index 00000000..9e9d780c --- /dev/null +++ b/app/Contracts/PaymentProvider.php @@ -0,0 +1,23 @@ + $details + */ + public function authorize(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult; + + public function capture(Payment $payment): PaymentResult; + + public function void(Payment $payment): PaymentResult; + + public function refund(Payment $payment, int $amount): RefundResult; +} diff --git a/app/Enums/AnalyticsEventType.php b/app/Enums/AnalyticsEventType.php new file mode 100644 index 00000000..9e06831b --- /dev/null +++ b/app/Enums/AnalyticsEventType.php @@ -0,0 +1,22 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/CartStatus.php b/app/Enums/CartStatus.php new file mode 100644 index 00000000..057f9008 --- /dev/null +++ b/app/Enums/CartStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/CheckoutStatus.php b/app/Enums/CheckoutStatus.php new file mode 100644 index 00000000..9414d9c8 --- /dev/null +++ b/app/Enums/CheckoutStatus.php @@ -0,0 +1,26 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } + + public function isActive(): bool + { + return ! in_array($this, [self::Completed, self::Expired], true); + } +} diff --git a/app/Enums/CollectionStatus.php b/app/Enums/CollectionStatus.php new file mode 100644 index 00000000..73e016bd --- /dev/null +++ b/app/Enums/CollectionStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/CollectionType.php b/app/Enums/CollectionType.php new file mode 100644 index 00000000..65b1752e --- /dev/null +++ b/app/Enums/CollectionType.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/DiscountStatus.php b/app/Enums/DiscountStatus.php new file mode 100644 index 00000000..2f463cbe --- /dev/null +++ b/app/Enums/DiscountStatus.php @@ -0,0 +1,19 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/DiscountType.php b/app/Enums/DiscountType.php new file mode 100644 index 00000000..2e838872 --- /dev/null +++ b/app/Enums/DiscountType.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/DiscountValueType.php b/app/Enums/DiscountValueType.php new file mode 100644 index 00000000..4fa112b7 --- /dev/null +++ b/app/Enums/DiscountValueType.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/FinancialStatus.php b/app/Enums/FinancialStatus.php new file mode 100644 index 00000000..763b59c0 --- /dev/null +++ b/app/Enums/FinancialStatus.php @@ -0,0 +1,26 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } + + public function allowsFulfillment(): bool + { + return in_array($this, [self::Paid, self::PartiallyRefunded], true); + } +} diff --git a/app/Enums/FulfillmentShipmentStatus.php b/app/Enums/FulfillmentShipmentStatus.php new file mode 100644 index 00000000..cded7678 --- /dev/null +++ b/app/Enums/FulfillmentShipmentStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/FulfillmentStatus.php b/app/Enums/FulfillmentStatus.php new file mode 100644 index 00000000..94126427 --- /dev/null +++ b/app/Enums/FulfillmentStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/InventoryPolicy.php b/app/Enums/InventoryPolicy.php new file mode 100644 index 00000000..c7ea5662 --- /dev/null +++ b/app/Enums/InventoryPolicy.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/MediaStatus.php b/app/Enums/MediaStatus.php new file mode 100644 index 00000000..6d6f75f2 --- /dev/null +++ b/app/Enums/MediaStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/MediaType.php b/app/Enums/MediaType.php new file mode 100644 index 00000000..eec85fa0 --- /dev/null +++ b/app/Enums/MediaType.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/NavigationItemType.php b/app/Enums/NavigationItemType.php new file mode 100644 index 00000000..f79dcbe5 --- /dev/null +++ b/app/Enums/NavigationItemType.php @@ -0,0 +1,19 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/OrderStatus.php b/app/Enums/OrderStatus.php new file mode 100644 index 00000000..99bcd377 --- /dev/null +++ b/app/Enums/OrderStatus.php @@ -0,0 +1,20 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/PageStatus.php b/app/Enums/PageStatus.php new file mode 100644 index 00000000..9c0cfee1 --- /dev/null +++ b/app/Enums/PageStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/PaymentMethod.php b/app/Enums/PaymentMethod.php new file mode 100644 index 00000000..3eccf873 --- /dev/null +++ b/app/Enums/PaymentMethod.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/PaymentStatus.php b/app/Enums/PaymentStatus.php new file mode 100644 index 00000000..51b93bf6 --- /dev/null +++ b/app/Enums/PaymentStatus.php @@ -0,0 +1,19 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/ProductStatus.php b/app/Enums/ProductStatus.php new file mode 100644 index 00000000..4f7ab52f --- /dev/null +++ b/app/Enums/ProductStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/RefundStatus.php b/app/Enums/RefundStatus.php new file mode 100644 index 00000000..37049ebc --- /dev/null +++ b/app/Enums/RefundStatus.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/ShippingRateType.php b/app/Enums/ShippingRateType.php new file mode 100644 index 00000000..81c23723 --- /dev/null +++ b/app/Enums/ShippingRateType.php @@ -0,0 +1,19 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/StoreDomainType.php b/app/Enums/StoreDomainType.php new file mode 100644 index 00000000..298bc9aa --- /dev/null +++ b/app/Enums/StoreDomainType.php @@ -0,0 +1,18 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/StoreStatus.php b/app/Enums/StoreStatus.php new file mode 100644 index 00000000..3714568f --- /dev/null +++ b/app/Enums/StoreStatus.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/StoreUserRole.php b/app/Enums/StoreUserRole.php new file mode 100644 index 00000000..79b69506 --- /dev/null +++ b/app/Enums/StoreUserRole.php @@ -0,0 +1,29 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } + + public function isOwnerOrAdmin(): bool + { + return in_array($this, [self::Owner, self::Admin], true); + } + + public function isOwnerAdminOrStaff(): bool + { + return in_array($this, [self::Owner, self::Admin, self::Staff], true); + } +} diff --git a/app/Enums/TaxMode.php b/app/Enums/TaxMode.php new file mode 100644 index 00000000..e4985f6b --- /dev/null +++ b/app/Enums/TaxMode.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/TaxProviderType.php b/app/Enums/TaxProviderType.php new file mode 100644 index 00000000..17e17d9e --- /dev/null +++ b/app/Enums/TaxProviderType.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/ThemeStatus.php b/app/Enums/ThemeStatus.php new file mode 100644 index 00000000..d6de1481 --- /dev/null +++ b/app/Enums/ThemeStatus.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/VariantStatus.php b/app/Enums/VariantStatus.php new file mode 100644 index 00000000..e3698056 --- /dev/null +++ b/app/Enums/VariantStatus.php @@ -0,0 +1,17 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Enums/WebhookTopic.php b/app/Enums/WebhookTopic.php new file mode 100644 index 00000000..d75715e7 --- /dev/null +++ b/app/Enums/WebhookTopic.php @@ -0,0 +1,27 @@ + + */ + public static function values(): array + { + return array_map(fn (self $case): string => $case->value, self::cases()); + } +} diff --git a/app/Events/CustomerCreated.php b/app/Events/CustomerCreated.php new file mode 100644 index 00000000..589000d2 --- /dev/null +++ b/app/Events/CustomerCreated.php @@ -0,0 +1,13 @@ +value} -> {$to->value}."); + } +} diff --git a/app/Exceptions/PaymentFailedException.php b/app/Exceptions/PaymentFailedException.php new file mode 100644 index 00000000..4aa8dcbf --- /dev/null +++ b/app/Exceptions/PaymentFailedException.php @@ -0,0 +1,13 @@ +stores()->wherePivot('store_id', $store)->exists(); + + if (! $hasAccess) { + abort(403); + } + + $request->session()->put('current_store_id', $store); + + return redirect('/admin'); + } +} diff --git a/app/Http/Controllers/Api/Admin/CollectionController.php b/app/Http/Controllers/Api/Admin/CollectionController.php new file mode 100644 index 00000000..7dfc67aa --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CollectionController.php @@ -0,0 +1,53 @@ +resolveStore($request, $storeId); + + $collections = CollectionModel::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderByDesc('id') + ->limit(100) + ->get(); + + return CollectionResource::collection($collections)->response(); + } + + public function show(Request $request, int $storeId, int $collectionId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $collection = CollectionModel::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($collectionId); + + return (new CollectionResource($collection))->response(); + } + + protected function resolveStore(Request $request, int $storeId): Store + { + $user = $request->user(); + $store = Store::query()->findOrFail($storeId); + + if ($user === null || ! $user->stores()->wherePivot('store_id', $store->getKey())->exists()) { + abort(403); + } + + app()->instance('current_store', $store); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Admin/CustomerController.php b/app/Http/Controllers/Api/Admin/CustomerController.php new file mode 100644 index 00000000..577ce17d --- /dev/null +++ b/app/Http/Controllers/Api/Admin/CustomerController.php @@ -0,0 +1,53 @@ +resolveStore($request, $storeId); + + $customers = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderByDesc('id') + ->limit(100) + ->get(); + + return CustomerResource::collection($customers)->response(); + } + + public function show(Request $request, int $storeId, int $customerId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $customer = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($customerId); + + return (new CustomerResource($customer))->response(); + } + + protected function resolveStore(Request $request, int $storeId): Store + { + $user = $request->user(); + $store = Store::query()->findOrFail($storeId); + + if ($user === null || ! $user->stores()->wherePivot('store_id', $store->getKey())->exists()) { + abort(403); + } + + app()->instance('current_store', $store); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Admin/OrderController.php b/app/Http/Controllers/Api/Admin/OrderController.php new file mode 100644 index 00000000..4fa45eee --- /dev/null +++ b/app/Http/Controllers/Api/Admin/OrderController.php @@ -0,0 +1,54 @@ +resolveStore($request, $storeId); + + $orders = Order::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderByDesc('placed_at') + ->limit(100) + ->get(); + + return OrderResource::collection($orders)->response(); + } + + public function show(Request $request, int $storeId, int $orderId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $order = Order::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->with('lines') + ->findOrFail($orderId); + + return (new OrderResource($order))->response(); + } + + protected function resolveStore(Request $request, int $storeId): Store + { + $user = $request->user(); + $store = Store::query()->findOrFail($storeId); + + if ($user === null || ! $user->stores()->wherePivot('store_id', $store->getKey())->exists()) { + abort(403); + } + + app()->instance('current_store', $store); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Admin/ProductController.php b/app/Http/Controllers/Api/Admin/ProductController.php new file mode 100644 index 00000000..369e3c17 --- /dev/null +++ b/app/Http/Controllers/Api/Admin/ProductController.php @@ -0,0 +1,109 @@ +resolveStore($request, $storeId); + + $products = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->with('variants') + ->orderByDesc('id') + ->limit(100) + ->get(); + + return ProductResource::collection($products)->response(); + } + + public function store(StoreProductRequest $request, int $storeId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + $this->authorizeAbility($request, 'write-products'); + + $product = $this->productService->create((int) $store->getKey(), $request->validated()); + + return (new ProductResource($product->load('variants')))->response()->setStatusCode(201); + } + + public function show(Request $request, int $storeId, int $productId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $product = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->with('variants', 'media') + ->findOrFail($productId); + + return (new ProductResource($product))->response(); + } + + public function update(UpdateProductRequest $request, int $storeId, int $productId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + $this->authorizeAbility($request, 'write-products'); + + $product = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($productId); + + $product = $this->productService->update($product, $request->validated()); + + return (new ProductResource($product->load('variants')))->response(); + } + + public function destroy(Request $request, int $storeId, int $productId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + $this->authorizeAbility($request, 'write-products'); + + $product = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($productId); + + $this->productService->delete($product); + + return response()->json(null, 204); + } + + protected function resolveStore(Request $request, int $storeId): Store + { + $user = $request->user(); + $store = Store::query()->findOrFail($storeId); + + if ($user === null || ! $user->stores()->wherePivot('store_id', $store->getKey())->exists()) { + abort(403); + } + + app()->instance('current_store', $store); + + return $store; + } + + protected function authorizeAbility(Request $request, string $ability): void + { + $token = $request->user()?->currentAccessToken(); + + if ($token !== null && method_exists($token, 'can') && ! $token->can($ability) && ! $token->can('*')) { + abort(403, 'Token missing required ability: '.$ability); + } + } +} diff --git a/app/Http/Controllers/Api/Admin/WebhookController.php b/app/Http/Controllers/Api/Admin/WebhookController.php new file mode 100644 index 00000000..9d2f03ac --- /dev/null +++ b/app/Http/Controllers/Api/Admin/WebhookController.php @@ -0,0 +1,78 @@ +resolveStore($request, $storeId); + + $subs = WebhookSubscription::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->orderByDesc('id') + ->get(); + + return WebhookSubscriptionResource::collection($subs)->response(); + } + + public function store(Request $request, int $storeId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $validated = $request->validate([ + 'event_type' => ['required', 'string', 'in:'.implode(',', WebhookTopic::values())], + 'target_url' => ['required', 'url', 'max:2048'], + 'signing_secret' => ['required', 'string', 'min:8'], + 'status' => ['nullable', 'string', 'in:active,paused,disabled'], + ]); + + $subscription = WebhookSubscription::query()->create([ + 'store_id' => $store->getKey(), + 'event_type' => $validated['event_type'], + 'target_url' => $validated['target_url'], + 'signing_secret_encrypted' => $validated['signing_secret'], + 'status' => $validated['status'] ?? 'active', + 'created_at' => now(), + ]); + + return (new WebhookSubscriptionResource($subscription))->response()->setStatusCode(201); + } + + public function destroy(Request $request, int $storeId, int $subscriptionId): JsonResponse + { + $store = $this->resolveStore($request, $storeId); + + $subscription = WebhookSubscription::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->findOrFail($subscriptionId); + + $subscription->delete(); + + return response()->json(null, 204); + } + + protected function resolveStore(Request $request, int $storeId): Store + { + $user = $request->user(); + $store = Store::query()->findOrFail($storeId); + + if ($user === null || ! $user->stores()->wherePivot('store_id', $store->getKey())->exists()) { + abort(403); + } + + app()->instance('current_store', $store); + + return $store; + } +} diff --git a/app/Http/Controllers/Api/Storefront/AnalyticsEventsController.php b/app/Http/Controllers/Api/Storefront/AnalyticsEventsController.php new file mode 100644 index 00000000..cfe93e98 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/AnalyticsEventsController.php @@ -0,0 +1,36 @@ +validate([ + 'type' => ['required', 'string', 'max:64'], + 'properties' => ['nullable', 'array'], + 'client_event_id' => ['nullable', 'string', 'max:128'], + ]); + + $store = app('current_store'); + + $event = $analytics->track( + $store, + (string) $validated['type'], + (array) ($validated['properties'] ?? []), + $request->session()?->getId(), + null, + $validated['client_event_id'] ?? null, + ); + + return response()->json([ + 'accepted' => true, + 'event_id' => $event?->getKey(), + ], 202); + } +} diff --git a/app/Http/Controllers/Api/Storefront/CartController.php b/app/Http/Controllers/Api/Storefront/CartController.php new file mode 100644 index 00000000..dcc572b3 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CartController.php @@ -0,0 +1,81 @@ +cartService->create($store); + + return (new CartResource($cart->load('lines')))->response()->setStatusCode(201); + } + + public function show(Cart $cart): JsonResponse + { + $this->assertStore($cart); + + return (new CartResource($cart->load('lines')))->response(); + } + + public function addLine(AddCartLineRequest $request, Cart $cart): JsonResponse + { + $this->assertStore($cart); + + try { + $this->cartService->addLine($cart, (int) $request->input('variant_id'), (int) $request->input('quantity')); + } catch (InsufficientInventoryException $e) { + return response()->json([ + 'error' => 'insufficient_inventory', + 'variant_id' => $e->variantId, + ], 422); + } catch (\RuntimeException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + return (new CartResource($cart->refresh()->load('lines')))->response(); + } + + public function updateLine(UpdateCartLineRequest $request, Cart $cart, int $line): JsonResponse + { + $this->assertStore($cart); + + try { + $this->cartService->updateLineQuantity($cart, $line, (int) $request->input('quantity')); + } catch (InsufficientInventoryException $e) { + return response()->json(['error' => 'insufficient_inventory'], 422); + } + + return (new CartResource($cart->refresh()->load('lines')))->response(); + } + + public function removeLine(Cart $cart, int $line): JsonResponse + { + $this->assertStore($cart); + + $this->cartService->removeLine($cart, $line); + + return (new CartResource($cart->refresh()->load('lines')))->response(); + } + + protected function assertStore(Cart $cart): void + { + $store = app('current_store'); + + if ((int) $cart->store_id !== (int) $store->getKey()) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Api/Storefront/CheckoutController.php b/app/Http/Controllers/Api/Storefront/CheckoutController.php new file mode 100644 index 00000000..5895541f --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/CheckoutController.php @@ -0,0 +1,131 @@ +input('cart_id'); + $cart = Cart::query()->where('store_id', $store->getKey())->findOrFail($cartId); + + $checkout = $this->checkoutService->start($store, $cart); + + return (new CheckoutResource($checkout))->response()->setStatusCode(201); + } + + public function show(Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + + return (new CheckoutResource($checkout))->response(); + } + + public function setAddress(SetCheckoutAddressRequest $request, Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + + try { + $this->checkoutService->setAddress($checkout, $request->validated()); + } catch (InvalidCheckoutStateException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + return (new CheckoutResource($checkout->refresh()))->response(); + } + + public function setShippingMethod(SetShippingMethodRequest $request, Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + + try { + $this->checkoutService->setShippingMethod($checkout, (int) $request->input('shipping_rate_id')); + } catch (InvalidCheckoutStateException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + return (new CheckoutResource($checkout->refresh()))->response(); + } + + public function applyDiscount(ApplyDiscountRequest $request, Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + + try { + $this->checkoutService->applyDiscount($checkout, (string) $request->input('code')); + } catch (InvalidDiscountException $e) { + return response()->json(['error' => $e->reason], 422); + } + + return (new CheckoutResource($checkout->refresh()))->response(); + } + + public function removeDiscount(Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + + $this->checkoutService->removeDiscount($checkout); + + return (new CheckoutResource($checkout->refresh()))->response(); + } + + public function pay(PayCheckoutRequest $request, Checkout $checkout): JsonResponse + { + $this->assertStore($checkout); + $method = PaymentMethod::from((string) $request->input('payment_method')); + + try { + $this->checkoutService->selectPaymentMethod($checkout, $method); + } catch (InvalidCheckoutStateException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + try { + $result = $this->paymentService->authorize($checkout->refresh(), $method, [ + 'card_number' => (string) $request->input('card_number', ''), + ]); + } catch (PaymentFailedException $e) { + return response()->json(['error' => $e->errorCode], 422); + } + + $order = $this->orderService->createFromCheckout($checkout->refresh()); + $this->paymentService->recordPayment($order, $method, $result); + + return (new OrderResource($order->load('lines')))->response()->setStatusCode(201); + } + + protected function assertStore(Checkout $checkout): void + { + $store = app('current_store'); + + if ((int) $checkout->store_id !== (int) $store->getKey()) { + abort(404); + } + } +} diff --git a/app/Http/Controllers/Api/Storefront/OrderController.php b/app/Http/Controllers/Api/Storefront/OrderController.php new file mode 100644 index 00000000..b06b71d2 --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/OrderController.php @@ -0,0 +1,24 @@ +where('store_id', $store->getKey()) + ->where('order_number', $orderNumber) + ->with('lines') + ->firstOrFail(); + + return (new OrderResource($order))->response(); + } +} diff --git a/app/Http/Controllers/Api/Storefront/SearchController.php b/app/Http/Controllers/Api/Storefront/SearchController.php new file mode 100644 index 00000000..9f531ebc --- /dev/null +++ b/app/Http/Controllers/Api/Storefront/SearchController.php @@ -0,0 +1,24 @@ +query('q', ''); + + $results = $search->search($store, $query, [], $request->session()?->getId()); + + return ProductResource::collection($results) + ->additional(['meta' => ['query' => $query, 'count' => $results->count()]]) + ->response(); + } +} diff --git a/app/Http/Middleware/ResolveStore.php b/app/Http/Middleware/ResolveStore.php new file mode 100644 index 00000000..0a46c825 --- /dev/null +++ b/app/Http/Middleware/ResolveStore.php @@ -0,0 +1,100 @@ +is('livewire/update'); + + $store = $mode === 'admin' + ? $this->resolveForAdmin($request) + : $this->resolveForStorefront($request); + + if ($store === null && $mode === 'admin' && $isLivewireUpdate) { + $store = $this->resolveForStorefront($request); + } + + if ($store === null) { + if ($isLivewireUpdate) { + return $next($request); + } + + abort($mode === 'admin' ? 403 : 404, 'Store not found.'); + } + + if ($store->status === StoreStatus::Suspended) { + if ($mode === 'admin' && in_array($request->method(), ['POST', 'PUT', 'PATCH', 'DELETE'], true)) { + abort(403, 'This store is currently unavailable.'); + } + + if ($mode === 'storefront' && ! $isLivewireUpdate) { + abort(503, 'This store is currently unavailable.'); + } + } + + app()->instance('current_store', $store); + View::share('currentStore', $store); + + return $next($request); + } + + protected function resolveForStorefront(Request $request): ?Store + { + $hostname = strtolower($request->getHost()); + $cacheKey = 'store-domains:'.$hostname; + + $storeId = Cache::remember($cacheKey, now()->addMinutes(5), function () use ($hostname): ?int { + return StoreDomain::query() + ->where('hostname', $hostname) + ->value('store_id'); + }); + + if ($storeId === null) { + Cache::forget($cacheKey); + + return null; + } + + return Store::query()->find($storeId); + } + + protected function resolveForAdmin(Request $request): ?Store + { + $storeId = $request->session()->get('current_store_id'); + + if ($storeId === null) { + return null; + } + + $user = Auth::user(); + + if ($user === null) { + return null; + } + + $hasAccess = $user->stores() + ->wherePivot('store_id', $storeId) + ->exists(); + + if (! $hasAccess) { + return null; + } + + return Store::query()->find($storeId); + } +} diff --git a/app/Http/Requests/Api/Admin/StoreProductRequest.php b/app/Http/Requests/Api/Admin/StoreProductRequest.php new file mode 100644 index 00000000..088b4041 --- /dev/null +++ b/app/Http/Requests/Api/Admin/StoreProductRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'status' => ['nullable', 'string', 'in:'.implode(',', ProductStatus::values())], + 'description_html' => ['nullable', 'string'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'product_type' => ['nullable', 'string', 'max:255'], + 'tags' => ['nullable', 'array'], + 'tags.*' => ['string', 'max:64'], + ]; + } +} diff --git a/app/Http/Requests/Api/Admin/UpdateProductRequest.php b/app/Http/Requests/Api/Admin/UpdateProductRequest.php new file mode 100644 index 00000000..fe0f5851 --- /dev/null +++ b/app/Http/Requests/Api/Admin/UpdateProductRequest.php @@ -0,0 +1,31 @@ +user() !== null; + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'title' => ['sometimes', 'required', 'string', 'max:255'], + 'handle' => ['sometimes', 'nullable', 'string', 'max:255'], + 'status' => ['sometimes', 'nullable', 'string', 'in:'.implode(',', ProductStatus::values())], + 'description_html' => ['sometimes', 'nullable', 'string'], + 'vendor' => ['sometimes', 'nullable', 'string', 'max:255'], + 'product_type' => ['sometimes', 'nullable', 'string', 'max:255'], + 'tags' => ['sometimes', 'nullable', 'array'], + 'tags.*' => ['string', 'max:64'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/AddCartLineRequest.php b/app/Http/Requests/Api/Storefront/AddCartLineRequest.php new file mode 100644 index 00000000..eba5e777 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/AddCartLineRequest.php @@ -0,0 +1,24 @@ +> + */ + public function rules(): array + { + return [ + 'variant_id' => ['required', 'integer'], + 'quantity' => ['required', 'integer', 'min:1'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php b/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php new file mode 100644 index 00000000..847cb3e1 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/ApplyDiscountRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return [ + 'code' => ['required', 'string', 'max:64'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php b/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php new file mode 100644 index 00000000..b77f48d2 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/PayCheckoutRequest.php @@ -0,0 +1,25 @@ +> + */ + public function rules(): array + { + return [ + 'payment_method' => ['required', 'string', 'in:'.implode(',', PaymentMethod::values())], + 'card_number' => ['nullable', 'string', 'max:32'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php b/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php new file mode 100644 index 00000000..012fee09 --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SetCheckoutAddressRequest.php @@ -0,0 +1,33 @@ +> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email'], + 'shipping_address.first_name' => ['required', 'string', 'max:255'], + 'shipping_address.last_name' => ['required', 'string', 'max:255'], + 'shipping_address.address1' => ['required', 'string', 'max:255'], + 'shipping_address.address2' => ['nullable', 'string', 'max:255'], + 'shipping_address.city' => ['required', 'string', 'max:255'], + 'shipping_address.province_code' => ['nullable', 'string', 'max:10'], + 'shipping_address.country_code' => ['required', 'string', 'size:2'], + 'shipping_address.postal_code' => ['required', 'string', 'max:20'], + 'shipping_address.phone' => ['nullable', 'string', 'max:50'], + 'billing_address' => ['nullable', 'array'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php b/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php new file mode 100644 index 00000000..259283be --- /dev/null +++ b/app/Http/Requests/Api/Storefront/SetShippingMethodRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return [ + 'shipping_rate_id' => ['required', 'integer'], + ]; + } +} diff --git a/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php b/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php new file mode 100644 index 00000000..eb02504e --- /dev/null +++ b/app/Http/Requests/Api/Storefront/UpdateCartLineRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return [ + 'quantity' => ['required', 'integer', 'min:0'], + ]; + } +} diff --git a/app/Http/Resources/CartLineResource.php b/app/Http/Resources/CartLineResource.php new file mode 100644 index 00000000..7fcd66de --- /dev/null +++ b/app/Http/Resources/CartLineResource.php @@ -0,0 +1,29 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'cart_id' => $this->cart_id, + 'variant_id' => $this->variant_id, + 'quantity' => (int) $this->quantity, + 'unit_price_amount' => (int) $this->unit_price_amount, + 'line_subtotal_amount' => (int) $this->line_subtotal_amount, + 'line_discount_amount' => (int) $this->line_discount_amount, + 'line_total_amount' => (int) $this->line_total_amount, + ]; + } +} diff --git a/app/Http/Resources/CartResource.php b/app/Http/Resources/CartResource.php new file mode 100644 index 00000000..57bdcc63 --- /dev/null +++ b/app/Http/Resources/CartResource.php @@ -0,0 +1,31 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'currency' => $this->currency, + 'cart_version' => (int) $this->cart_version, + 'status' => $this->status?->value, + 'subtotal' => (int) $this->resource->subtotal(), + 'lines' => CartLineResource::collection($this->whenLoaded('lines')), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/CheckoutResource.php b/app/Http/Resources/CheckoutResource.php new file mode 100644 index 00000000..8da23124 --- /dev/null +++ b/app/Http/Resources/CheckoutResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'cart_id' => $this->cart_id, + 'customer_id' => $this->customer_id, + 'status' => $this->status?->value, + 'payment_method' => $this->payment_method?->value, + 'email' => $this->email, + 'shipping_address' => $this->shipping_address_json, + 'billing_address' => $this->billing_address_json, + 'shipping_method_id' => $this->shipping_method_id, + 'discount_code' => $this->discount_code, + 'totals' => $this->totals_json, + 'expires_at' => $this->expires_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/CollectionResource.php b/app/Http/Resources/CollectionResource.php new file mode 100644 index 00000000..29deb06b --- /dev/null +++ b/app/Http/Resources/CollectionResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->description_html, + 'type' => $this->type?->value ?? $this->type, + 'status' => $this->status?->value ?? $this->status, + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/CustomerResource.php b/app/Http/Resources/CustomerResource.php new file mode 100644 index 00000000..e223bde2 --- /dev/null +++ b/app/Http/Resources/CustomerResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'email' => $this->email, + 'name' => $this->name, + 'marketing_opt_in' => (bool) $this->marketing_opt_in, + 'email_verified_at' => $this->email_verified_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Http/Resources/OrderLineResource.php b/app/Http/Resources/OrderLineResource.php new file mode 100644 index 00000000..6849c18b --- /dev/null +++ b/app/Http/Resources/OrderLineResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_id' => $this->order_id, + 'product_id' => $this->product_id, + 'variant_id' => $this->variant_id, + 'title' => $this->title_snapshot, + 'sku' => $this->sku_snapshot, + 'quantity' => (int) $this->quantity, + 'unit_price_amount' => (int) $this->unit_price_amount, + 'total_amount' => (int) $this->total_amount, + 'tax_lines' => $this->tax_lines_json ?? [], + 'discount_allocations' => $this->discount_allocations_json ?? [], + ]; + } +} diff --git a/app/Http/Resources/OrderResource.php b/app/Http/Resources/OrderResource.php new file mode 100644 index 00000000..e8c0b9b3 --- /dev/null +++ b/app/Http/Resources/OrderResource.php @@ -0,0 +1,40 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'customer_id' => $this->customer_id, + 'order_number' => $this->order_number, + 'payment_method' => $this->payment_method?->value, + 'status' => $this->status?->value, + 'financial_status' => $this->financial_status?->value, + 'fulfillment_status' => $this->fulfillment_status?->value, + 'currency' => $this->currency, + 'subtotal_amount' => (int) $this->subtotal_amount, + 'discount_amount' => (int) $this->discount_amount, + 'shipping_amount' => (int) $this->shipping_amount, + 'tax_amount' => (int) $this->tax_amount, + 'total_amount' => (int) $this->total_amount, + 'email' => $this->email, + 'billing_address' => $this->billing_address_json, + 'shipping_address' => $this->shipping_address_json, + 'placed_at' => $this->placed_at?->toIso8601String(), + 'lines' => OrderLineResource::collection($this->whenLoaded('lines')), + ]; + } +} diff --git a/app/Http/Resources/ProductMediaResource.php b/app/Http/Resources/ProductMediaResource.php new file mode 100644 index 00000000..e3f60489 --- /dev/null +++ b/app/Http/Resources/ProductMediaResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'type' => $this->type?->value ?? $this->type, + 'storage_key' => $this->storage_key, + 'alt_text' => $this->alt_text, + 'width' => $this->width, + 'height' => $this->height, + 'mime_type' => $this->mime_type, + 'position' => (int) $this->position, + 'status' => $this->status?->value ?? $this->status, + ]; + } +} diff --git a/app/Http/Resources/ProductResource.php b/app/Http/Resources/ProductResource.php new file mode 100644 index 00000000..6f09b060 --- /dev/null +++ b/app/Http/Resources/ProductResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'title' => $this->title, + 'handle' => $this->handle, + 'status' => $this->status?->value, + 'description_html' => $this->description_html, + 'vendor' => $this->vendor, + 'product_type' => $this->product_type, + 'tags' => $this->tags ?? [], + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at?->toIso8601String(), + 'updated_at' => $this->updated_at?->toIso8601String(), + 'variants' => ProductVariantResource::collection($this->whenLoaded('variants')), + 'media' => ProductMediaResource::collection($this->whenLoaded('media')), + ]; + } +} diff --git a/app/Http/Resources/ProductVariantResource.php b/app/Http/Resources/ProductVariantResource.php new file mode 100644 index 00000000..bfb739a0 --- /dev/null +++ b/app/Http/Resources/ProductVariantResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'product_id' => $this->product_id, + 'sku' => $this->sku, + 'barcode' => $this->barcode, + 'price_amount' => (int) $this->price_amount, + 'compare_at_amount' => $this->compare_at_amount !== null ? (int) $this->compare_at_amount : null, + 'currency' => $this->currency, + 'weight_g' => $this->weight_g, + 'requires_shipping' => (bool) $this->requires_shipping, + 'is_default' => (bool) $this->is_default, + 'position' => (int) $this->position, + 'status' => $this->status?->value, + ]; + } +} diff --git a/app/Http/Resources/WebhookSubscriptionResource.php b/app/Http/Resources/WebhookSubscriptionResource.php new file mode 100644 index 00000000..e2f97db2 --- /dev/null +++ b/app/Http/Resources/WebhookSubscriptionResource.php @@ -0,0 +1,28 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'store_id' => $this->store_id, + 'event_type' => $this->event_type, + 'target_url' => $this->target_url, + 'status' => $this->status, + 'consecutive_failures' => (int) $this->consecutive_failures, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/DeliverWebhook.php b/app/Jobs/DeliverWebhook.php new file mode 100644 index 00000000..a55d7f17 --- /dev/null +++ b/app/Jobs/DeliverWebhook.php @@ -0,0 +1,157 @@ + + */ + public array $backoff = [30, 60, 120, 300, 900, 3600, 7200]; + + /** + * @param array $payload + */ + public function __construct( + public readonly int $subscriptionId, + public readonly string $eventId, + public readonly string $topic, + public readonly array $payload, + public readonly int $timestamp, + ) {} + + public function handle(): void + { + $subscription = WebhookSubscription::query()->withoutGlobalScopes()->find($this->subscriptionId); + + if ($subscription === null || $subscription->status !== 'active') { + return; + } + + $body = json_encode([ + 'event_id' => $this->eventId, + 'topic' => $this->topic, + 'timestamp' => $this->timestamp, + 'data' => $this->payload, + ]); + + $signature = hash_hmac('sha256', (string) $body, (string) $subscription->signing_secret_encrypted); + + $delivery = WebhookDelivery::query()->create([ + 'subscription_id' => $subscription->getKey(), + 'event_id' => $this->eventId, + 'attempt_count' => max(1, $this->attempts()), + 'status' => 'pending', + 'last_attempt_at' => now(), + ]); + + try { + $response = Http::withHeaders([ + 'Content-Type' => 'application/json', + 'X-Shop-Topic' => $this->topic, + 'X-Shop-Signature' => $signature, + 'X-Shop-Event-Id' => $this->eventId, + 'X-Shop-Timestamp' => (string) $this->timestamp, + ])->withBody($body, 'application/json')->post($subscription->target_url); + + $delivery->response_code = $response->status(); + $delivery->response_body_snippet = substr((string) $response->body(), 0, 500); + + if ($response->successful()) { + $delivery->status = 'success'; + $delivery->next_retry_at = null; + $delivery->save(); + + $subscription->consecutive_failures = 0; + $subscription->save(); + + return; + } + + $this->recordFailure($subscription, $delivery); + + if ($this->shouldRethrowForRetry()) { + throw new \RuntimeException("Webhook delivery failed with HTTP {$response->status()}."); + } + } catch (\RuntimeException $e) { + throw $e; + } catch (Throwable $e) { + $delivery->response_body_snippet = substr($e->getMessage(), 0, 500); + $this->recordFailure($subscription, $delivery); + + Log::warning('webhook.delivery_failed', [ + 'subscription_id' => $subscription->getKey(), + 'topic' => $this->topic, + 'error' => $e->getMessage(), + ]); + + if ($this->shouldRethrowForRetry()) { + throw $e; + } + } + } + + protected function shouldRethrowForRetry(): bool + { + $connection = $this->connection ?? config('queue.default'); + + return $connection !== 'sync'; + } + + public function failed(Throwable $exception): void + { + $delivery = WebhookDelivery::query() + ->where('subscription_id', $this->subscriptionId) + ->where('event_id', $this->eventId) + ->latest('id') + ->first(); + + if ($delivery !== null) { + $delivery->status = 'failed'; + $delivery->next_retry_at = null; + $delivery->save(); + } + } + + protected function recordFailure(WebhookSubscription $subscription, WebhookDelivery $delivery): void + { + $delivery->status = 'pending'; + $delivery->next_retry_at = $this->nextRetryAt(); + $delivery->save(); + + $subscription->consecutive_failures = (int) $subscription->consecutive_failures + 1; + + if ($subscription->consecutive_failures >= 5) { + $subscription->status = 'paused'; + } + + $subscription->save(); + } + + protected function nextRetryAt(): ?\Carbon\CarbonInterface + { + $attempt = max(1, $this->attempts()); + $delay = $this->backoff[$attempt - 1] ?? null; + + if ($delay === null) { + return null; + } + + return now()->addSeconds((int) $delay); + } +} diff --git a/app/Jobs/ProcessMediaUpload.php b/app/Jobs/ProcessMediaUpload.php new file mode 100644 index 00000000..aa23eee7 --- /dev/null +++ b/app/Jobs/ProcessMediaUpload.php @@ -0,0 +1,72 @@ +find($this->mediaId); + + if ($media === null) { + return; + } + + try { + $disk = Storage::disk('public'); + + if (! $disk->exists($media->storage_key)) { + $media->update(['status' => MediaStatus::Failed->value]); + + return; + } + + $contents = $disk->get($media->storage_key); + $mimeType = $disk->mimeType($media->storage_key) ?: $media->mime_type; + $byteSize = $disk->size($media->storage_key); + + $width = $media->width; + $height = $media->height; + + if ($contents !== null && function_exists('getimagesizefromstring')) { + $info = @getimagesizefromstring($contents); + + if (is_array($info)) { + $width = $info[0] ?? $width; + $height = $info[1] ?? $height; + $mimeType = $info['mime'] ?? $mimeType; + } + } + + $media->update([ + 'width' => $width, + 'height' => $height, + 'mime_type' => $mimeType, + 'byte_size' => $byteSize, + 'status' => MediaStatus::Ready->value, + ]); + } catch (\Throwable $e) { + Log::warning('ProcessMediaUpload failed', [ + 'media_id' => $this->mediaId, + 'error' => $e->getMessage(), + ]); + $media->update(['status' => MediaStatus::Failed->value]); + } + } +} diff --git a/app/Listeners/DispatchWebhooks.php b/app/Listeners/DispatchWebhooks.php new file mode 100644 index 00000000..e7c48e79 --- /dev/null +++ b/app/Listeners/DispatchWebhooks.php @@ -0,0 +1,124 @@ +dispatcher->dispatch( + WebhookTopic::OrderCreated, + (int) $event->order->store_id, + $this->orderPayload($event->order), + ); + } + + public function handleOrderPaid(OrderPaid $event): void + { + $this->dispatcher->dispatch( + WebhookTopic::OrderPaid, + (int) $event->order->store_id, + $this->orderPayload($event->order), + ); + } + + public function handleOrderCancelled(OrderCancelled $event): void + { + $this->dispatcher->dispatch( + WebhookTopic::OrderCancelled, + (int) $event->order->store_id, + $this->orderPayload($event->order), + ); + } + + public function handleOrderRefunded(OrderRefunded $event): void + { + $this->dispatcher->dispatch( + WebhookTopic::OrderRefunded, + (int) $event->order->store_id, + array_merge($this->orderPayload($event->order), [ + 'refund_id' => $event->refund->getKey(), + 'refund_amount' => (int) $event->refund->amount, + ]), + ); + } + + public function handleOrderFulfilled(OrderFulfilled $event): void + { + $this->dispatcher->dispatch( + WebhookTopic::OrderFulfilled, + (int) $event->order->store_id, + $this->orderPayload($event->order), + ); + } + + public function handleFulfillmentCreated(FulfillmentCreated $event): void + { + $fulfillment = $event->fulfillment->load('order'); + $this->dispatcher->dispatch( + WebhookTopic::FulfillmentCreated, + (int) ($fulfillment->order?->store_id ?? 0), + [ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_id' => $fulfillment->order_id, + 'status' => $fulfillment->status?->value, + ], + ); + } + + public function handleFulfillmentShipped(FulfillmentShipped $event): void + { + $fulfillment = $event->fulfillment->load('order'); + $this->dispatcher->dispatch( + WebhookTopic::FulfillmentShipped, + (int) ($fulfillment->order?->store_id ?? 0), + [ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_id' => $fulfillment->order_id, + 'tracking_number' => $fulfillment->tracking_number, + ], + ); + } + + public function handleCustomerCreated(CustomerCreated $event): void + { + $this->dispatcher->dispatch( + WebhookTopic::CustomerCreated, + (int) $event->customer->store_id, + [ + 'customer_id' => $event->customer->getKey(), + 'email' => $event->customer->email, + 'name' => $event->customer->name, + ], + ); + } + + /** + * @return array + */ + protected function orderPayload(\App\Models\Order $order): array + { + return [ + 'order_id' => $order->getKey(), + 'order_number' => $order->order_number, + 'status' => $order->status?->value, + 'financial_status' => $order->financial_status?->value, + 'fulfillment_status' => $order->fulfillment_status?->value, + 'total_amount' => (int) $order->total_amount, + 'currency' => $order->currency, + ]; + } +} diff --git a/app/Livewire/Admin/Auth/Login.php b/app/Livewire/Admin/Auth/Login.php new file mode 100644 index 00000000..8c5e17d8 --- /dev/null +++ b/app/Livewire/Admin/Auth/Login.php @@ -0,0 +1,67 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + $throttleKey = 'login:'.request()->ip(); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'email' => "Too many attempts. Try again in {$seconds} seconds.", + ]); + } + + if (! Auth::guard('web')->attempt( + ['email' => $this->email, 'password' => $this->password], + $this->remember, + )) { + RateLimiter::hit($throttleKey, 60); + + throw ValidationException::withMessages([ + 'email' => 'Invalid credentials', + ]); + } + + RateLimiter::clear($throttleKey); + request()->session()->regenerate(); + + $user = Auth::guard('web')->user(); + $firstStoreId = $user?->stores()->value('stores.id'); + + if ($firstStoreId !== null) { + request()->session()->put('current_store_id', $firstStoreId); + } + + $user?->forceFill(['last_login_at' => now()])->saveQuietly(); + + return redirect()->intended('/admin'); + } + + public function render(): mixed + { + return view('livewire.admin.auth.login'); + } +} diff --git a/app/Livewire/Admin/Auth/Logout.php b/app/Livewire/Admin/Auth/Logout.php new file mode 100644 index 00000000..b491e26b --- /dev/null +++ b/app/Livewire/Admin/Auth/Logout.php @@ -0,0 +1,29 @@ +logout(); + + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect('/admin/login'); + } + + public function logout(): mixed + { + return $this->__invoke(); + } + + public function render(): mixed + { + return view('livewire.admin.auth.logout'); + } +} diff --git a/app/Livewire/Admin/Collections/Edit.php b/app/Livewire/Admin/Collections/Edit.php new file mode 100644 index 00000000..823f0f5d --- /dev/null +++ b/app/Livewire/Admin/Collections/Edit.php @@ -0,0 +1,94 @@ +findOrFail($collection); + $this->authorize('view', $model); + $this->collectionId = (int) $model->getKey(); + $this->title = (string) $model->title; + $this->handle = (string) $model->handle; + $this->descriptionHtml = (string) ($model->description_html ?? ''); + $this->status = $model->status->value; + + return; + } + + $this->authorize('create', CollectionModel::class); + } + + public function save(): mixed + { + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:active,archived'], + ]); + + $storeId = (int) app('current_store')->getKey(); + + if ($this->collectionId !== null) { + $model = CollectionModel::query()->findOrFail($this->collectionId); + $this->authorize('update', $model); + $model->title = $this->title; + $model->description_html = $this->descriptionHtml !== '' ? $this->descriptionHtml : null; + $model->status = CollectionStatus::from($this->status); + + if ($this->handle !== '') { + $model->handle = HandleGenerator::unique(CollectionModel::class, $storeId, $this->handle, $model->getKey()); + } + + $model->save(); + session()->flash('status', 'Collection updated.'); + + return redirect('/admin/collections/'.$model->getKey().'/edit'); + } + + $this->authorize('create', CollectionModel::class); + + $handle = HandleGenerator::unique(CollectionModel::class, $storeId, $this->handle !== '' ? $this->handle : $this->title); + + $created = new CollectionModel([ + 'title' => $this->title, + 'handle' => $handle, + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'type' => CollectionType::Manual->value, + 'status' => CollectionStatus::from($this->status)->value, + ]); + $created->store_id = $storeId; + $created->save(); + + session()->flash('status', 'Collection created.'); + + return redirect('/admin/collections/'.$created->getKey().'/edit'); + } + + public function render(): View + { + return view('livewire.admin.collections.edit'); + } +} diff --git a/app/Livewire/Admin/Collections/Index.php b/app/Livewire/Admin/Collections/Index.php new file mode 100644 index 00000000..b224ef6d --- /dev/null +++ b/app/Livewire/Admin/Collections/Index.php @@ -0,0 +1,44 @@ +authorize('viewAny', Collection::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): View + { + $this->authorize('viewAny', Collection::class); + + $query = Collection::query()->withCount('products'); + + if (trim($this->search) !== '') { + $query->where('title', 'like', '%'.trim($this->search).'%'); + } + + return view('livewire.admin.collections.index', [ + 'collections' => $query->orderByDesc('updated_at')->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Index.php b/app/Livewire/Admin/Customers/Index.php new file mode 100644 index 00000000..e993bb3b --- /dev/null +++ b/app/Livewire/Admin/Customers/Index.php @@ -0,0 +1,49 @@ +authorize('viewAny', Customer::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): View + { + $this->authorize('viewAny', Customer::class); + + $query = Customer::query() + ->withCount('orders') + ->withSum('orders as total_spent', 'total_amount'); + + if (trim($this->search) !== '') { + $term = '%'.trim($this->search).'%'; + $query->where(function ($q) use ($term): void { + $q->where('name', 'like', $term)->orWhere('email', 'like', $term); + }); + } + + return view('livewire.admin.customers.index', [ + 'customers' => $query->orderByDesc('created_at')->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Customers/Show.php b/app/Livewire/Admin/Customers/Show.php new file mode 100644 index 00000000..e7201775 --- /dev/null +++ b/app/Livewire/Admin/Customers/Show.php @@ -0,0 +1,32 @@ +findOrFail($customer); + $this->authorize('view', $model); + $this->customerId = (int) $model->getKey(); + } + + public function render(): View + { + $customer = Customer::query() + ->with(['orders' => fn ($q) => $q->orderByDesc('placed_at')->limit(25), 'addresses']) + ->findOrFail($this->customerId); + + return view('livewire.admin.customers.show', [ + 'customer' => $customer, + ]); + } +} diff --git a/app/Livewire/Admin/Dashboard.php b/app/Livewire/Admin/Dashboard.php new file mode 100644 index 00000000..f97e23e3 --- /dev/null +++ b/app/Livewire/Admin/Dashboard.php @@ -0,0 +1,35 @@ +forDay($store); + + $recentOrders = Order::query() + ->orderByDesc('placed_at') + ->limit(10) + ->get(); + + return view('livewire.admin.dashboard', [ + 'revenueToday' => $today['revenue_amount'], + 'ordersCount' => $today['orders_count'], + 'aov' => $today['aov_amount'], + 'visitsToday' => $today['visits_count'], + 'addToCartToday' => $today['add_to_cart_count'], + 'checkoutStartedToday' => $today['checkout_started_count'], + 'checkoutCompletedToday' => $today['checkout_completed_count'], + 'recentOrders' => $recentOrders, + ]); + } +} diff --git a/app/Livewire/Admin/Discounts/Edit.php b/app/Livewire/Admin/Discounts/Edit.php new file mode 100644 index 00000000..f6f7af98 --- /dev/null +++ b/app/Livewire/Admin/Discounts/Edit.php @@ -0,0 +1,108 @@ +findOrFail($discount); + $this->authorize('view', $model); + $this->discountId = (int) $model->getKey(); + $this->type = $model->type->value; + $this->code = (string) ($model->code ?? ''); + $this->valueType = $model->value_type->value; + $this->valueAmount = (int) $model->value_amount; + $this->startsAt = optional($model->starts_at)->format('Y-m-d\TH:i'); + $this->endsAt = optional($model->ends_at)->format('Y-m-d\TH:i'); + $this->usageLimit = $model->usage_limit; + $this->status = $model->status->value; + + return; + } + + $this->authorize('create', Discount::class); + $this->startsAt = now()->format('Y-m-d\TH:i'); + } + + public function save(): mixed + { + $this->validate([ + 'type' => ['required', 'in:code,automatic'], + 'code' => ['nullable', 'string', 'max:255', 'required_if:type,code'], + 'valueType' => ['required', 'in:percent,fixed,free_shipping'], + 'valueAmount' => ['required', 'integer', 'min:0'], + 'startsAt' => ['required', 'date'], + 'endsAt' => ['nullable', 'date', 'after:startsAt'], + 'usageLimit' => ['nullable', 'integer', 'min:1'], + 'status' => ['required', 'in:draft,active,expired,disabled'], + ]); + + $storeId = (int) app('current_store')->getKey(); + + $data = [ + 'type' => DiscountType::from($this->type)->value, + 'code' => $this->type === 'code' ? $this->code : null, + 'value_type' => DiscountValueType::from($this->valueType)->value, + 'value_amount' => $this->valueAmount, + 'starts_at' => $this->startsAt, + 'ends_at' => $this->endsAt !== '' ? $this->endsAt : null, + 'usage_limit' => $this->usageLimit, + 'status' => DiscountStatus::from($this->status)->value, + 'rules_json' => null, + ]; + + if ($this->discountId !== null) { + $model = Discount::query()->findOrFail($this->discountId); + $this->authorize('update', $model); + $model->fill($data); + $model->save(); + session()->flash('status', 'Discount updated.'); + + return redirect('/admin/discounts/'.$model->getKey().'/edit'); + } + + $this->authorize('create', Discount::class); + $created = new Discount($data); + $created->store_id = $storeId; + $created->usage_count = 0; + $created->save(); + + session()->flash('status', 'Discount created.'); + + return redirect('/admin/discounts/'.$created->getKey().'/edit'); + } + + public function render(): View + { + return view('livewire.admin.discounts.edit'); + } +} diff --git a/app/Livewire/Admin/Discounts/Index.php b/app/Livewire/Admin/Discounts/Index.php new file mode 100644 index 00000000..a7be712e --- /dev/null +++ b/app/Livewire/Admin/Discounts/Index.php @@ -0,0 +1,44 @@ +authorize('viewAny', Discount::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render(): View + { + $this->authorize('viewAny', Discount::class); + + $query = Discount::query(); + + if (trim($this->search) !== '') { + $query->where('code', 'like', '%'.trim($this->search).'%'); + } + + return view('livewire.admin.discounts.index', [ + 'discounts' => $query->orderByDesc('created_at')->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Index.php b/app/Livewire/Admin/Orders/Index.php new file mode 100644 index 00000000..697f7724 --- /dev/null +++ b/app/Livewire/Admin/Orders/Index.php @@ -0,0 +1,60 @@ +authorize('viewAny', Order::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function render(): View + { + $this->authorize('viewAny', Order::class); + + $query = Order::query(); + + if ($this->statusFilter !== 'all') { + $query->where('financial_status', $this->statusFilter); + } + + if (trim($this->search) !== '') { + $term = '%'.trim($this->search).'%'; + $query->where(function ($q) use ($term): void { + $q->where('order_number', 'like', $term) + ->orWhere('email', 'like', $term); + }); + } + + return view('livewire.admin.orders.index', [ + 'orders' => $query->orderByDesc('placed_at')->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Orders/Show.php b/app/Livewire/Admin/Orders/Show.php new file mode 100644 index 00000000..f71c577f --- /dev/null +++ b/app/Livewire/Admin/Orders/Show.php @@ -0,0 +1,220 @@ + */ + public array $fulfillmentLineQuantities = []; + + public string $trackingCompany = ''; + + public string $trackingNumber = ''; + + public string $trackingUrl = ''; + + public ?int $refundAmount = null; + + public string $refundReason = ''; + + public function mount(int $order): void + { + $model = Order::query()->findOrFail($order); + $this->authorize('view', $model); + + $this->orderId = (int) $model->getKey(); + } + + public function confirmPayment(InventoryService $inventory): void + { + $order = $this->resolveOrder(); + $this->authorize('update', $order); + + if ($order->payment_method !== PaymentMethod::BankTransfer || $order->financial_status !== FinancialStatus::Pending) { + $this->addError('payment', 'Only pending bank transfer orders can be confirmed.'); + + return; + } + + foreach ($order->lines()->with('variant')->get() as $line) { + if ($line->variant !== null) { + $inventory->commit($line->variant, (int) $line->quantity); + } + } + + $order->financial_status = FinancialStatus::Paid; + $order->status = OrderStatus::Paid; + $order->save(); + + OrderPaid::dispatch($order->refresh()); + + session()->flash('status', 'Payment confirmed.'); + } + + public function openFulfillmentModal(): void + { + $order = $this->resolveOrder(); + + $this->fulfillmentLineQuantities = []; + foreach ($order->lines as $line) { + $this->fulfillmentLineQuantities[$line->getKey()] = (int) $line->unfulfilledQuantity(); + } + + $this->showFulfillmentModal = true; + } + + public function createFulfillment(FulfillmentService $service): void + { + $order = $this->resolveOrder(); + $this->authorize('createFulfillment', $order); + + $lines = []; + foreach ($this->fulfillmentLineQuantities as $lineId => $qty) { + $qty = (int) $qty; + if ($qty > 0) { + $lines[] = ['order_line_id' => (int) $lineId, 'quantity' => $qty]; + } + } + + if ($lines === []) { + $this->addError('fulfillment', 'Select at least one line to fulfill.'); + + return; + } + + try { + $service->create($order, $lines, [ + 'tracking_company' => $this->trackingCompany !== '' ? $this->trackingCompany : null, + 'tracking_number' => $this->trackingNumber !== '' ? $this->trackingNumber : null, + 'tracking_url' => $this->trackingUrl !== '' ? $this->trackingUrl : null, + ]); + } catch (\Throwable $e) { + $this->addError('fulfillment', $e->getMessage()); + + return; + } + + $this->reset(['showFulfillmentModal', 'trackingCompany', 'trackingNumber', 'trackingUrl']); + session()->flash('status', 'Fulfillment created.'); + } + + public function markAsShipped(int $fulfillmentId, FulfillmentService $service): void + { + $order = $this->resolveOrder(); + $this->authorize('update', $order); + + $fulfillment = $order->fulfillments()->findOrFail($fulfillmentId); + $service->markAsShipped($fulfillment); + session()->flash('status', 'Fulfillment marked shipped.'); + } + + public function markAsDelivered(int $fulfillmentId, FulfillmentService $service): void + { + $order = $this->resolveOrder(); + $this->authorize('update', $order); + + $fulfillment = $order->fulfillments()->findOrFail($fulfillmentId); + $service->markAsDelivered($fulfillment); + session()->flash('status', 'Fulfillment marked delivered.'); + } + + public function openRefundModal(): void + { + $order = $this->resolveOrder(); + $this->refundAmount = (int) $order->remainingRefundable(); + $this->showRefundModal = true; + } + + public function createRefund(RefundService $service): void + { + $order = $this->resolveOrder(); + $this->authorize('createRefund', $order); + + $payment = $order->payments()->orderByDesc('id')->first(); + + if ($payment === null) { + $this->addError('refund', 'No captured payment to refund.'); + + return; + } + + $amount = (int) $this->refundAmount; + + if ($amount <= 0) { + $this->addError('refund', 'Enter a positive refund amount.'); + + return; + } + + try { + $service->create($order, $payment, $amount, $this->refundReason !== '' ? $this->refundReason : null); + } catch (\Throwable $e) { + $this->addError('refund', $e->getMessage()); + + return; + } + + $this->reset(['showRefundModal', 'refundReason', 'refundAmount']); + session()->flash('status', 'Refund issued.'); + } + + public function cancelOrder(OrderService $service): void + { + $order = $this->resolveOrder(); + $this->authorize('cancel', $order); + + try { + $service->cancel($order); + } catch (\Throwable $e) { + $this->addError('cancel', $e->getMessage()); + + return; + } + + session()->flash('status', 'Order cancelled.'); + } + + public function render(): View + { + $order = Order::query() + ->with(['lines.variant.product', 'payments', 'refunds', 'fulfillments.lines', 'customer']) + ->findOrFail($this->orderId); + + return view('livewire.admin.orders.show', [ + 'order' => $order, + 'canRefund' => auth()->user()?->can('createRefund', $order) ?? false, + 'canCancel' => auth()->user()?->can('cancel', $order) ?? false, + 'canFulfill' => auth()->user()?->can('createFulfillment', $order) ?? false, + 'shipmentPending' => FulfillmentShipmentStatus::Pending, + 'shipmentShipped' => FulfillmentShipmentStatus::Shipped, + 'fulfilled' => FulfillmentStatus::Fulfilled, + ]); + } + + protected function resolveOrder(): Order + { + return Order::query()->findOrFail($this->orderId); + } +} diff --git a/app/Livewire/Admin/Pages/Edit.php b/app/Livewire/Admin/Pages/Edit.php new file mode 100644 index 00000000..c30e2033 --- /dev/null +++ b/app/Livewire/Admin/Pages/Edit.php @@ -0,0 +1,98 @@ +findOrFail($page); + $this->authorize('view', $model); + $this->pageId = (int) $model->getKey(); + $this->title = (string) $model->title; + $this->handle = (string) $model->handle; + $this->bodyHtml = (string) ($model->body_html ?? ''); + $this->status = $model->status->value; + + return; + } + + $this->authorize('create', Page::class); + } + + public function save(): mixed + { + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'bodyHtml' => ['nullable', 'string', 'max:65535'], + 'status' => ['required', 'in:'.implode(',', PageStatus::values())], + ]); + + $storeId = (int) app('current_store')->getKey(); + + $data = [ + 'title' => $this->title, + 'body_html' => $this->bodyHtml !== '' ? $this->bodyHtml : null, + 'status' => PageStatus::from($this->status)->value, + ]; + + if ($this->pageId !== null) { + $model = Page::query()->findOrFail($this->pageId); + $this->authorize('update', $model); + + if ($this->handle !== '') { + $data['handle'] = HandleGenerator::unique(Page::class, $storeId, $this->handle, $model->getKey()); + } + + if ($this->status === 'published' && $model->published_at === null) { + $data['published_at'] = now(); + } + + $model->fill($data); + $model->save(); + session()->flash('status', 'Page updated.'); + + return redirect('/admin/pages/'.$model->getKey().'/edit'); + } + + $this->authorize('create', Page::class); + $data['handle'] = HandleGenerator::unique(Page::class, $storeId, $this->handle !== '' ? $this->handle : $this->title); + + if ($this->status === 'published') { + $data['published_at'] = now(); + } + + $created = new Page($data); + $created->store_id = $storeId; + $created->save(); + + session()->flash('status', 'Page created.'); + + return redirect('/admin/pages/'.$created->getKey().'/edit'); + } + + public function render(): View + { + return view('livewire.admin.pages.edit'); + } +} diff --git a/app/Livewire/Admin/Pages/Index.php b/app/Livewire/Admin/Pages/Index.php new file mode 100644 index 00000000..c65433de --- /dev/null +++ b/app/Livewire/Admin/Pages/Index.php @@ -0,0 +1,29 @@ +authorize('viewAny', Page::class); + } + + public function render(): View + { + $this->authorize('viewAny', Page::class); + + return view('livewire.admin.pages.index', [ + 'pages' => Page::query()->orderByDesc('updated_at')->paginate(20), + ]); + } +} diff --git a/app/Livewire/Admin/Products/Create.php b/app/Livewire/Admin/Products/Create.php new file mode 100644 index 00000000..3595893d --- /dev/null +++ b/app/Livewire/Admin/Products/Create.php @@ -0,0 +1,80 @@ +authorize('create', Product::class); + } + + public function save(ProductService $service): mixed + { + $this->authorize('create', Product::class); + + $data = $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['nullable', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'status' => ['required', 'in:'.implode(',', ProductStatus::values())], + 'tags' => ['nullable', 'string'], + 'priceAmount' => ['required', 'integer', 'min:0'], + 'quantityOnHand' => ['required', 'integer', 'min:0'], + ]); + + $tagsArray = array_values(array_filter(array_map('trim', explode(',', $this->tags)))); + + $product = $service->create((int) app('current_store')->getKey(), [ + 'title' => $data['title'], + 'handle' => $this->handle !== '' ? $this->handle : null, + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'vendor' => $this->vendor !== '' ? $this->vendor : null, + 'product_type' => $this->productType !== '' ? $this->productType : null, + 'tags' => $tagsArray, + 'price_amount' => $this->priceAmount, + 'quantity_on_hand' => $this->quantityOnHand, + ]); + + if ($this->status !== 'draft') { + $service->transitionStatus($product, ProductStatus::from($this->status)); + } + + session()->flash('status', 'Product created.'); + + return redirect('/admin/products/'.$product->getKey().'/edit'); + } + + public function render(): View + { + return view('livewire.admin.products.create'); + } +} diff --git a/app/Livewire/Admin/Products/Edit.php b/app/Livewire/Admin/Products/Edit.php new file mode 100644 index 00000000..30a5d897 --- /dev/null +++ b/app/Livewire/Admin/Products/Edit.php @@ -0,0 +1,133 @@ + + */ + public array $variants = []; + + public function mount(int $product): void + { + $model = Product::query()->with(['variants.inventoryItem'])->findOrFail($product); + $this->authorize('view', $model); + + $this->productId = (int) $model->getKey(); + $this->title = (string) $model->title; + $this->handle = (string) $model->handle; + $this->descriptionHtml = (string) ($model->description_html ?? ''); + $this->vendor = (string) ($model->vendor ?? ''); + $this->productType = (string) ($model->product_type ?? ''); + $this->status = $model->status->value; + $this->tags = implode(', ', $model->tags ?? []); + + $this->variants = $model->variants->map(fn ($v): array => [ + 'id' => (int) $v->getKey(), + 'sku' => $v->sku, + 'price_amount' => (int) $v->price_amount, + 'compare_at_amount' => $v->compare_at_amount !== null ? (int) $v->compare_at_amount : null, + 'weight_g' => $v->weight_g !== null ? (int) $v->weight_g : null, + 'quantity_on_hand' => (int) (optional($v->inventoryItem)->quantity_on_hand ?? 0), + ])->values()->all(); + } + + public function save(ProductService $service): mixed + { + $model = Product::query()->findOrFail($this->productId); + $this->authorize('update', $model); + + $this->validate([ + 'title' => ['required', 'string', 'max:255'], + 'handle' => ['required', 'string', 'max:255'], + 'descriptionHtml' => ['nullable', 'string', 'max:65535'], + 'vendor' => ['nullable', 'string', 'max:255'], + 'productType' => ['nullable', 'string', 'max:255'], + 'status' => ['required', 'in:'.implode(',', ProductStatus::values())], + 'tags' => ['nullable', 'string'], + 'variants.*.price_amount' => ['required', 'integer', 'min:0'], + 'variants.*.quantity_on_hand' => ['required', 'integer', 'min:0'], + ]); + + $tagsArray = array_values(array_filter(array_map('trim', explode(',', $this->tags)))); + + $service->update($model, [ + 'title' => $this->title, + 'handle' => $this->handle, + 'description_html' => $this->descriptionHtml !== '' ? $this->descriptionHtml : null, + 'vendor' => $this->vendor !== '' ? $this->vendor : null, + 'product_type' => $this->productType !== '' ? $this->productType : null, + 'tags' => $tagsArray, + ]); + + foreach ($this->variants as $row) { + $variant = $model->variants()->find($row['id']); + + if ($variant === null) { + continue; + } + + $variant->sku = $row['sku'] ?: null; + $variant->price_amount = (int) $row['price_amount']; + $variant->compare_at_amount = $row['compare_at_amount']; + $variant->weight_g = $row['weight_g']; + $variant->save(); + + if ($variant->inventoryItem) { + $variant->inventoryItem->quantity_on_hand = (int) $row['quantity_on_hand']; + $variant->inventoryItem->save(); + } + } + + $current = ProductStatus::from($model->fresh()->status->value); + $target = ProductStatus::from($this->status); + + if ($current !== $target) { + try { + $service->transitionStatus($model->fresh(), $target); + } catch (\Throwable $e) { + $this->addError('status', $e->getMessage()); + + return null; + } + } + + session()->flash('status', 'Product updated.'); + + return redirect('/admin/products/'.$this->productId.'/edit'); + } + + public function render(): View + { + $product = Product::query()->with(['variants.inventoryItem', 'media'])->findOrFail($this->productId); + + return view('livewire.admin.products.edit', [ + 'product' => $product, + ]); + } +} diff --git a/app/Livewire/Admin/Products/Index.php b/app/Livewire/Admin/Products/Index.php new file mode 100644 index 00000000..ad427539 --- /dev/null +++ b/app/Livewire/Admin/Products/Index.php @@ -0,0 +1,73 @@ +authorize('viewAny', Product::class); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function render(SearchService $search): View + { + $this->authorize('viewAny', Product::class); + + $term = trim($this->search); + $store = app('current_store'); + + $query = Product::query()->with(['variants.inventoryItem', 'media']); + + if ($this->statusFilter !== 'all') { + $query->where('status', $this->statusFilter); + } + + if (mb_strlen($term) >= 2) { + $matched = $search->search($store, $term, log: false); + $ids = $matched->pluck('id')->all(); + + if ($ids === []) { + $query->whereRaw('1 = 0'); + } else { + $query->whereIn('id', $ids); + } + } + + /** @var LengthAwarePaginator $products */ + $products = $query->orderByDesc('updated_at')->paginate(20); + + return view('livewire.admin.products.index', [ + 'products' => $products, + 'statuses' => array_merge(['all'], ProductStatus::values()), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/General.php b/app/Livewire/Admin/Settings/General.php new file mode 100644 index 00000000..e416c80c --- /dev/null +++ b/app/Livewire/Admin/Settings/General.php @@ -0,0 +1,61 @@ +authorize('viewSettings', $store); + $this->storeName = $store->name; + $this->storeHandle = $store->handle; + $this->defaultCurrency = $store->default_currency; + $this->defaultLocale = $store->default_locale; + $this->timezone = $store->timezone; + } + + public function save(): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + $this->validate([ + 'storeName' => ['required', 'string', 'max:255'], + 'defaultCurrency' => ['required', 'string', 'size:3'], + 'defaultLocale' => ['required', 'string', 'max:10'], + 'timezone' => ['required', 'string', 'max:64'], + ]); + + /** @var Store $model */ + $model = Store::query()->findOrFail($store->getKey()); + $model->name = $this->storeName; + $model->default_currency = strtoupper($this->defaultCurrency); + $model->default_locale = $this->defaultLocale; + $model->timezone = $this->timezone; + $model->save(); + + session()->flash('status', 'Store settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.general'); + } +} diff --git a/app/Livewire/Admin/Settings/Shipping.php b/app/Livewire/Admin/Settings/Shipping.php new file mode 100644 index 00000000..7cfa44f2 --- /dev/null +++ b/app/Livewire/Admin/Settings/Shipping.php @@ -0,0 +1,70 @@ + */ + public array $newZoneCountries = []; + + public function mount(): void + { + $store = app('current_store'); + $this->authorize('viewSettings', $store); + } + + public function addZone(): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + $this->validate([ + 'newZoneName' => ['required', 'string', 'max:255'], + ]); + + $zone = new ShippingZone([ + 'name' => $this->newZoneName, + 'countries_json' => $this->newZoneCountries, + 'regions_json' => [], + ]); + $zone->store_id = (int) $store->getKey(); + $zone->save(); + + $this->reset(['newZoneName', 'newZoneCountries']); + session()->flash('status', 'Zone added.'); + } + + public function deleteZone(int $zoneId): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + ShippingZone::query()->where('id', $zoneId)->where('store_id', $store->getKey())->delete(); + session()->flash('status', 'Zone removed.'); + } + + public function deleteRate(int $rateId): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + ShippingRate::query()->where('id', $rateId)->delete(); + session()->flash('status', 'Rate removed.'); + } + + public function render(): View + { + $zones = ShippingZone::query()->with('rates')->get(); + + return view('livewire.admin.settings.shipping', ['zones' => $zones]); + } +} diff --git a/app/Livewire/Admin/Settings/Staff.php b/app/Livewire/Admin/Settings/Staff.php new file mode 100644 index 00000000..9c247b7b --- /dev/null +++ b/app/Livewire/Admin/Settings/Staff.php @@ -0,0 +1,64 @@ +authorize('viewSettings', $store); + } + + public function openInvite(): void + { + $this->showInviteModal = true; + } + + public function invite(): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + $this->validate([ + 'inviteEmail' => ['required', 'email'], + 'inviteRole' => ['required', 'in:'.implode(',', StoreUserRole::values())], + ]); + + $user = User::query()->where('email', $this->inviteEmail)->first(); + + if ($user !== null && ! $user->stores()->where('store_id', $store->getKey())->exists()) { + $user->stores()->attach($store->getKey(), [ + 'role' => $this->inviteRole, + 'created_at' => now(), + ]); + } + + $this->reset(['showInviteModal', 'inviteEmail', 'inviteRole']); + session()->flash('status', 'Staff invited.'); + } + + public function render(): View + { + $store = app('current_store'); + $members = $store->users()->get(); + + return view('livewire.admin.settings.staff', [ + 'members' => $members, + 'roles' => StoreUserRole::values(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Taxes.php b/app/Livewire/Admin/Settings/Taxes.php new file mode 100644 index 00000000..60dc933a --- /dev/null +++ b/app/Livewire/Admin/Settings/Taxes.php @@ -0,0 +1,65 @@ +authorize('viewSettings', $store); + + $settings = TaxSettings::query()->find($store->getKey()); + + if ($settings !== null) { + $this->mode = $settings->mode->value; + $this->provider = $settings->provider->value; + $this->pricesIncludeTax = (bool) $settings->prices_include_tax; + } + } + + public function save(): void + { + $store = app('current_store'); + $this->authorize('updateSettings', $store); + + $this->validate([ + 'mode' => ['required', 'in:'.implode(',', TaxMode::values())], + 'provider' => ['required', 'in:'.implode(',', TaxProviderType::values())], + ]); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::from($this->mode)->value, + 'provider' => TaxProviderType::from($this->provider)->value, + 'prices_include_tax' => $this->pricesIncludeTax ? 1 : 0, + 'config_json' => [], + ], + ); + + session()->flash('status', 'Tax settings saved.'); + } + + public function render(): View + { + return view('livewire.admin.settings.taxes', [ + 'modes' => TaxMode::values(), + 'providers' => TaxProviderType::values(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Webhooks/Deliveries.php b/app/Livewire/Admin/Settings/Webhooks/Deliveries.php new file mode 100644 index 00000000..fd665f5d --- /dev/null +++ b/app/Livewire/Admin/Settings/Webhooks/Deliveries.php @@ -0,0 +1,35 @@ +subscriptionId = $subscription; + } + + public function render(): View + { + $subscription = WebhookSubscription::query()->findOrFail($this->subscriptionId); + $deliveries = WebhookDelivery::query() + ->where('subscription_id', $subscription->getKey()) + ->orderByDesc('id') + ->limit(100) + ->get(); + + return view('livewire.admin.settings.webhooks.deliveries', [ + 'subscription' => $subscription, + 'deliveries' => $deliveries, + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Webhooks/Edit.php b/app/Livewire/Admin/Settings/Webhooks/Edit.php new file mode 100644 index 00000000..07d8160a --- /dev/null +++ b/app/Livewire/Admin/Settings/Webhooks/Edit.php @@ -0,0 +1,75 @@ +findOrFail($subscription); + $this->subscriptionId = (int) $row->getKey(); + $this->event_type = (string) $row->event_type; + $this->target_url = (string) $row->target_url; + $this->signing_secret = (string) $row->signing_secret_encrypted; + $this->status = (string) $row->status; + } else { + $this->event_type = WebhookTopic::OrderPaid->value; + $this->signing_secret = Str::random(32); + } + } + + public function save(): mixed + { + $this->validate([ + 'event_type' => 'required|string|in:'.implode(',', WebhookTopic::values()), + 'target_url' => 'required|url', + 'signing_secret' => 'required|string|min:8', + 'status' => 'required|in:active,paused,disabled', + ]); + + $store = app('current_store'); + + $attributes = [ + 'store_id' => $store->getKey(), + 'event_type' => $this->event_type, + 'target_url' => $this->target_url, + 'signing_secret_encrypted' => $this->signing_secret, + 'status' => $this->status, + 'created_at' => now(), + ]; + + if ($this->subscriptionId === null) { + WebhookSubscription::query()->create($attributes); + } else { + WebhookSubscription::query()->findOrFail($this->subscriptionId)->update($attributes); + } + + return redirect('/admin/settings/webhooks'); + } + + public function render(): View + { + return view('livewire.admin.settings.webhooks.edit', [ + 'topics' => WebhookTopic::cases(), + ]); + } +} diff --git a/app/Livewire/Admin/Settings/Webhooks/Index.php b/app/Livewire/Admin/Settings/Webhooks/Index.php new file mode 100644 index 00000000..c57a6849 --- /dev/null +++ b/app/Livewire/Admin/Settings/Webhooks/Index.php @@ -0,0 +1,23 @@ +orderByDesc('id') + ->get(); + + return view('livewire.admin.settings.webhooks.index', [ + 'subscriptions' => $subscriptions, + ]); + } +} diff --git a/app/Livewire/Admin/Themes/Index.php b/app/Livewire/Admin/Themes/Index.php new file mode 100644 index 00000000..6f2c24e7 --- /dev/null +++ b/app/Livewire/Admin/Themes/Index.php @@ -0,0 +1,47 @@ +authorize('viewAny', Theme::class); + } + + public function publish(int $themeId): void + { + $theme = Theme::query()->findOrFail($themeId); + $this->authorize('publish', $theme); + + DB::transaction(function () use ($theme): void { + Theme::query() + ->where('store_id', $theme->store_id) + ->where('id', '!=', $theme->getKey()) + ->update(['status' => ThemeStatus::Draft->value]); + + $theme->status = ThemeStatus::Published; + $theme->published_at = now(); + $theme->save(); + }); + + session()->flash('status', 'Theme published.'); + } + + public function render(): View + { + $this->authorize('viewAny', Theme::class); + + return view('livewire.admin.themes.index', [ + 'themes' => Theme::query()->orderByDesc('updated_at')->get(), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Addresses.php b/app/Livewire/Storefront/Account/Addresses.php new file mode 100644 index 00000000..7991ed7d --- /dev/null +++ b/app/Livewire/Storefront/Account/Addresses.php @@ -0,0 +1,158 @@ +validate([ + 'label' => 'nullable|string|max:100', + 'first_name' => 'required|string|max:100', + 'last_name' => 'required|string|max:100', + 'address1' => 'required|string|max:255', + 'city' => 'required|string|max:100', + 'country_code' => 'required|string|size:2', + 'postal_code' => 'required|string|max:20', + ]); + + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $payload = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'address1' => $this->address1, + 'city' => $this->city, + 'province_code' => $this->province_code, + 'country_code' => $this->country_code, + 'postal_code' => $this->postal_code, + ]; + + if ($this->editingId !== null) { + $address = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('id', $this->editingId) + ->firstOrFail(); + + $address->label = $this->label ?: null; + $address->address_json = $payload; + $address->is_default = $this->is_default ? 1 : 0; + $address->save(); + } else { + $address = CustomerAddress::query()->create([ + 'customer_id' => $customer->getKey(), + 'label' => $this->label ?: null, + 'address_json' => $payload, + 'is_default' => $this->is_default ? 1 : 0, + ]); + } + + if ($this->is_default) { + CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('id', '!=', $address->getKey()) + ->update(['is_default' => 0]); + } + + $this->resetForm(); + } + + public function edit(int $id): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $address = CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('id', $id) + ->firstOrFail(); + + $data = $address->address_json ?? []; + $this->editingId = $id; + $this->label = (string) ($address->label ?? ''); + $this->first_name = (string) ($data['first_name'] ?? ''); + $this->last_name = (string) ($data['last_name'] ?? ''); + $this->address1 = (string) ($data['address1'] ?? ''); + $this->city = (string) ($data['city'] ?? ''); + $this->province_code = (string) ($data['province_code'] ?? ''); + $this->country_code = (string) ($data['country_code'] ?? 'US'); + $this->postal_code = (string) ($data['postal_code'] ?? ''); + $this->is_default = (bool) $address->is_default; + } + + public function delete(int $id): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->where('id', $id) + ->delete(); + + if ($this->editingId === $id) { + $this->resetForm(); + } + } + + public function cancel(): void + { + $this->resetForm(); + } + + protected function resetForm(): void + { + $this->editingId = null; + $this->label = ''; + $this->first_name = ''; + $this->last_name = ''; + $this->address1 = ''; + $this->city = ''; + $this->province_code = ''; + $this->country_code = 'US'; + $this->postal_code = ''; + $this->is_default = false; + } + + public function render(): View + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + return view('livewire.storefront.account.addresses', [ + 'addresses' => CustomerAddress::query() + ->where('customer_id', $customer->getKey()) + ->orderByDesc('is_default') + ->get(), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/EmailVerify.php b/app/Livewire/Storefront/Account/Auth/EmailVerify.php new file mode 100644 index 00000000..4d245728 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/EmailVerify.php @@ -0,0 +1,25 @@ +withoutGlobalScopes()->findOrFail($id); + + if (! hash_equals(sha1((string) $customer->email), $hash)) { + abort(403); + } + + if ($customer->email_verified_at === null) { + $customer->email_verified_at = now(); + $customer->save(); + } + + return redirect('/account'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ForgotPassword.php b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php new file mode 100644 index 00000000..980da716 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ForgotPassword.php @@ -0,0 +1,31 @@ +validate(['email' => 'required|email']); + + $response = Password::broker('customers')->sendResetLink(['email' => $this->email]); + + $this->status = $response === Password::RESET_LINK_SENT + ? 'If an account exists for that email we have sent a reset link.' + : 'If an account exists for that email we have sent a reset link.'; + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.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..2eefcfaa --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Login.php @@ -0,0 +1,61 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required|string', + ]); + + $throttleKey = 'customer-login:'.request()->ip().':'.strtolower($this->email); + + if (RateLimiter::tooManyAttempts($throttleKey, 5)) { + $seconds = RateLimiter::availableIn($throttleKey); + + throw ValidationException::withMessages([ + 'email' => "Too many attempts. Try again in {$seconds} seconds.", + ]); + } + + if (! Auth::guard('customer')->attempt( + ['email' => $this->email, 'password' => $this->password], + $this->remember, + )) { + RateLimiter::hit($throttleKey, 60); + + throw ValidationException::withMessages([ + 'email' => 'Invalid credentials', + ]); + } + + RateLimiter::clear($throttleKey); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + return redirect()->intended('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.login'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Logout.php b/app/Livewire/Storefront/Account/Auth/Logout.php new file mode 100644 index 00000000..21b04e03 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Logout.php @@ -0,0 +1,19 @@ +logout(); + + request()->session()->invalidate(); + request()->session()->regenerateToken(); + + return redirect('/'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/Register.php b/app/Livewire/Storefront/Account/Auth/Register.php new file mode 100644 index 00000000..af4939b9 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/Register.php @@ -0,0 +1,68 @@ +getKey() : null; + + $this->validate([ + 'name' => 'required|string|max:255', + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('customers', 'email')->where(fn ($q) => $q->where('store_id', $storeId)), + ], + 'password' => 'required|string|min:8|confirmed', + 'marketing_opt_in' => 'boolean', + ]); + + $customer = new Customer; + $customer->store_id = $storeId; + $customer->email = $this->email; + $customer->password_hash = Hash::make($this->password); + $customer->name = $this->name; + $customer->marketing_opt_in = $this->marketing_opt_in ? 1 : 0; + $customer->save(); + + if (! Auth::guard('customer')->attempt( + ['email' => $this->email, 'password' => $this->password], + )) { + throw ValidationException::withMessages(['email' => 'Could not sign in after registration.']); + } + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + return redirect('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.register'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/ResetPassword.php b/app/Livewire/Storefront/Account/Auth/ResetPassword.php new file mode 100644 index 00000000..e3a7ea19 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/ResetPassword.php @@ -0,0 +1,62 @@ +token = $token; + } + + public function resetPassword(): mixed + { + $this->validate([ + 'email' => 'required|email', + 'password' => 'required|string|min:8|confirmed', + 'token' => 'required|string', + ]); + + $status = Password::broker('customers')->reset( + [ + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'token' => $this->token, + ], + function (Customer $customer, string $password): void { + $customer->password_hash = Hash::make($password); + $customer->save(); + }, + ); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['email' => 'Invalid or expired reset link.']); + } + + return redirect('/account/login')->with('status', 'Password reset successfully.'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.reset-password'); + } +} diff --git a/app/Livewire/Storefront/Account/Auth/SetPassword.php b/app/Livewire/Storefront/Account/Auth/SetPassword.php new file mode 100644 index 00000000..338ea892 --- /dev/null +++ b/app/Livewire/Storefront/Account/Auth/SetPassword.php @@ -0,0 +1,68 @@ +validate([ + 'email' => 'required|email', + 'password' => 'required|string|min:8|confirmed', + 'token' => 'required|string', + ]); + + $status = Password::broker('customers')->reset( + [ + 'email' => $this->email, + 'password' => $this->password, + 'password_confirmation' => $this->password_confirmation, + 'token' => $this->token, + ], + function (Customer $customer, string $password): void { + $customer->password_hash = Hash::make($password); + if ($customer->email_verified_at === null) { + $customer->email_verified_at = now(); + } + $customer->save(); + }, + ); + + if ($status !== Password::PASSWORD_RESET) { + throw ValidationException::withMessages(['email' => 'Invalid or expired link.']); + } + + Auth::guard('customer')->attempt(['email' => $this->email, 'password' => $this->password]); + + if (request()->hasSession()) { + request()->session()->regenerate(); + } + + return redirect('/account'); + } + + public function render(): mixed + { + return view('livewire.storefront.account.auth.set-password'); + } +} diff --git a/app/Livewire/Storefront/Account/Dashboard.php b/app/Livewire/Storefront/Account/Dashboard.php new file mode 100644 index 00000000..c6eac512 --- /dev/null +++ b/app/Livewire/Storefront/Account/Dashboard.php @@ -0,0 +1,32 @@ +user(); + + $recentOrders = Order::query() + ->where('customer_id', $customer->getKey()) + ->orderByDesc('placed_at') + ->limit(5) + ->get(); + + return view('livewire.storefront.account.dashboard', [ + 'customer' => $customer, + 'recentOrders' => $recentOrders instanceof Collection ? $recentOrders : collect($recentOrders), + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Index.php b/app/Livewire/Storefront/Account/Orders/Index.php new file mode 100644 index 00000000..8a1af88d --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Index.php @@ -0,0 +1,29 @@ +user(); + + $orders = Order::query() + ->where('customer_id', $customer->getKey()) + ->orderByDesc('placed_at') + ->get(); + + return view('livewire.storefront.account.orders.index', [ + 'orders' => $orders, + ]); + } +} diff --git a/app/Livewire/Storefront/Account/Orders/Show.php b/app/Livewire/Storefront/Account/Orders/Show.php new file mode 100644 index 00000000..4e6c6455 --- /dev/null +++ b/app/Livewire/Storefront/Account/Orders/Show.php @@ -0,0 +1,43 @@ +orderNumber = $orderNumber; + + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $order = Order::query() + ->where('customer_id', $customer->getKey()) + ->where('order_number', $orderNumber) + ->with('lines') + ->first(); + + if ($order === null) { + abort(404); + } + + $this->order = $order; + } + + public function render(): View + { + return view('livewire.storefront.account.orders.show'); + } +} diff --git a/app/Livewire/Storefront/Account/Profile.php b/app/Livewire/Storefront/Account/Profile.php new file mode 100644 index 00000000..302b60a2 --- /dev/null +++ b/app/Livewire/Storefront/Account/Profile.php @@ -0,0 +1,93 @@ +user(); + $this->name = (string) $customer->name; + $this->email = (string) $customer->email; + $this->marketing_opt_in = (bool) $customer->marketing_opt_in; + } + + public function saveProfile(): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $this->validate([ + 'name' => 'required|string|max:255', + 'email' => [ + 'required', + 'email', + 'max:255', + Rule::unique('customers', 'email') + ->where(fn ($q) => $q->where('store_id', $customer->store_id)) + ->ignore($customer->getKey()), + ], + 'marketing_opt_in' => 'boolean', + ]); + + $customer->name = $this->name; + $customer->email = $this->email; + $customer->marketing_opt_in = $this->marketing_opt_in ? 1 : 0; + $customer->save(); + + $this->status = 'Profile updated.'; + } + + public function changePassword(): void + { + /** @var Customer $customer */ + $customer = Auth::guard('customer')->user(); + + $this->validate([ + 'current_password' => 'required|string', + 'password' => 'required|string|min:8|confirmed', + ]); + + if (! $customer->hasPassword() || ! Hash::check($this->current_password, $customer->getAuthPassword())) { + throw ValidationException::withMessages(['current_password' => 'Current password is incorrect.']); + } + + $customer->password_hash = Hash::make($this->password); + $customer->save(); + + $this->current_password = ''; + $this->password = ''; + $this->password_confirmation = ''; + $this->status = 'Password updated.'; + } + + public function render(): View + { + return view('livewire.storefront.account.profile'); + } +} diff --git a/app/Livewire/Storefront/Cart/Drawer.php b/app/Livewire/Storefront/Cart/Drawer.php new file mode 100644 index 00000000..3ce9b3f3 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Drawer.php @@ -0,0 +1,40 @@ +open = ! $this->open; + } + + public function close(): void + { + $this->open = false; + } + + #[On('cart-updated')] + public function refreshDrawer(): void + { + // Livewire re-renders when the event fires. + } + + public function render(): View + { + $cartId = session('cart_id'); + $cart = $cartId ? CartModel::query()->find($cartId) : null; + + return view('livewire.storefront.cart.drawer', [ + 'cart' => $cart, + 'lines' => $cart ? $cart->lines()->with('variant.product')->get() : collect(), + ]); + } +} diff --git a/app/Livewire/Storefront/Cart/Show.php b/app/Livewire/Storefront/Cart/Show.php new file mode 100644 index 00000000..05dd2076 --- /dev/null +++ b/app/Livewire/Storefront/Cart/Show.php @@ -0,0 +1,66 @@ +cartId = session('cart_id'); + } + + public function updateQuantity(int $lineId, int $quantity): void + { + $cart = $this->loadCart(); + + if ($cart === null) { + return; + } + + try { + app(CartService::class)->updateLineQuantity($cart, $lineId, $quantity); + } catch (InsufficientInventoryException $e) { + $this->addError('line_'.$lineId, 'Not enough stock available.'); + } + } + + public function removeLine(int $lineId): void + { + $cart = $this->loadCart(); + + if ($cart === null) { + return; + } + + app(CartService::class)->removeLine($cart, $lineId); + } + + public function render(): View + { + $cart = $this->loadCart(); + + return view('livewire.storefront.cart.show', [ + 'cart' => $cart, + 'lines' => $cart ? $cart->lines()->with('variant.product')->get() : collect(), + ]); + } + + protected function loadCart(): ?CartModel + { + if ($this->cartId === null) { + return null; + } + + return CartModel::query()->find($this->cartId); + } +} diff --git a/app/Livewire/Storefront/Checkout/Show.php b/app/Livewire/Storefront/Checkout/Show.php new file mode 100644 index 00000000..b398b712 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Show.php @@ -0,0 +1,252 @@ +value; + + public string $card_number = ''; + + public string $payment_error = ''; + + public function mount(): void + { + $store = app('current_store'); + $cartId = session('cart_id'); + + if ($cartId === null) { + Redirect::to('/cart')->send(); + + return; + } + + $cart = CartModel::query()->find($cartId); + + if ($cart === null || $cart->lines()->count() === 0) { + Redirect::to('/cart')->send(); + + return; + } + + $checkout = app(CheckoutService::class)->start($store, $cart); + $this->checkoutId = (int) $checkout->getKey(); + + if ($checkout->email !== null) { + $this->email = $checkout->email; + } + + $address = $checkout->shipping_address_json ?? []; + $this->first_name = (string) ($address['first_name'] ?? ''); + $this->last_name = (string) ($address['last_name'] ?? ''); + $this->address1 = (string) ($address['address1'] ?? ''); + $this->city = (string) ($address['city'] ?? ''); + $this->province_code = (string) ($address['province_code'] ?? ''); + $this->country_code = (string) ($address['country_code'] ?? 'US'); + $this->postal_code = (string) ($address['postal_code'] ?? ''); + + $this->shipping_rate_id = $checkout->shipping_method_id ? (int) $checkout->shipping_method_id : null; + $this->discount_code = $checkout->discount_code ?? ''; + $this->payment_method = $checkout->payment_method?->value ?? PaymentMethod::CreditCard->value; + } + + public function saveAddress(): void + { + $this->validate([ + 'email' => 'required|email', + 'first_name' => 'required|string', + 'last_name' => 'required|string', + 'address1' => 'required|string', + 'city' => 'required|string', + 'country_code' => 'required|string|size:2', + 'postal_code' => 'required|string', + ]); + + $checkout = $this->loadCheckout(); + + try { + app(CheckoutService::class)->setAddress($checkout, [ + 'email' => $this->email, + 'shipping_address' => $this->addressPayload(), + ]); + } catch (InvalidCheckoutStateException $e) { + $this->addError('email', $e->getMessage()); + } + } + + public function selectShipping(int $rateId): void + { + $checkout = $this->loadCheckout(); + $this->shipping_rate_id = $rateId; + + try { + app(CheckoutService::class)->setShippingMethod($checkout, $rateId); + } catch (InvalidCheckoutStateException $e) { + $this->addError('shipping_rate_id', $e->getMessage()); + } + } + + public function applyDiscount(): void + { + $this->discount_error = ''; + + $checkout = $this->loadCheckout(); + + try { + app(CheckoutService::class)->applyDiscount($checkout, $this->discount_code); + } catch (InvalidDiscountException $e) { + $this->discount_error = $e->reason; + } + } + + public function removeDiscount(): void + { + $checkout = $this->loadCheckout(); + $this->discount_code = ''; + app(CheckoutService::class)->removeDiscount($checkout); + } + + public function selectPayment(): void + { + $this->validate(['payment_method' => 'required|string']); + + $method = PaymentMethod::tryFrom($this->payment_method) ?? PaymentMethod::CreditCard; + $checkout = $this->loadCheckout(); + + try { + app(CheckoutService::class)->selectPaymentMethod($checkout, $method); + } catch (InvalidCheckoutStateException $e) { + $this->addError('payment_method', $e->getMessage()); + } + } + + public function place(): mixed + { + $this->payment_error = ''; + + $checkout = $this->loadCheckout(); + $method = PaymentMethod::tryFrom($this->payment_method) ?? PaymentMethod::CreditCard; + + try { + app(CheckoutService::class)->selectPaymentMethod($checkout, $method); + } catch (InvalidCheckoutStateException $e) { + $this->payment_error = $e->getMessage(); + + return null; + } + + $details = $method === PaymentMethod::CreditCard ? ['card_number' => $this->card_number] : []; + + try { + $paymentResult = app(PaymentService::class)->authorize($checkout, $method, $details); + } catch (PaymentFailedException $e) { + $this->payment_error = $e->errorCode; + + return null; + } + + $order = app(OrderService::class)->createFromCheckout($checkout); + app(PaymentService::class)->recordPayment($order, $method, $paymentResult); + + session()->forget('cart_id'); + + return redirect('/checkout/success?order='.urlencode($order->order_number)); + } + + public function render(): View + { + $checkout = $this->loadCheckout(); + $cart = $checkout->cart; + $lines = $cart->lines()->with('variant.product')->get(); + $store = app('current_store'); + + $rates = collect(); + + if ($checkout->status !== CheckoutStatus::Started && ! empty($checkout->shipping_address_json)) { + $rates = app(ShippingCalculator::class)->getAvailableRates( + $store, + $checkout->shipping_address_json ?? [], + ); + } + + return view('livewire.storefront.checkout.show', [ + 'checkout' => $checkout, + 'cart' => $cart, + 'lines' => $lines, + 'rates' => $rates, + 'totals' => $checkout->totals_json ?? [], + 'paymentMethods' => PaymentMethod::cases(), + ]); + } + + /** + * @return array + */ + protected function addressPayload(): array + { + return [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'address1' => $this->address1, + 'city' => $this->city, + 'province_code' => $this->province_code, + 'country_code' => $this->country_code, + 'postal_code' => $this->postal_code, + ]; + } + + protected function loadCheckout(): Checkout + { + return Checkout::query()->findOrFail($this->checkoutId); + } + + /** + * Simple wrapper so tests can invoke a fresh cart creation when needed. + */ + protected function cartService(): CartService + { + return app(CartService::class); + } +} diff --git a/app/Livewire/Storefront/Checkout/Success.php b/app/Livewire/Storefront/Checkout/Success.php new file mode 100644 index 00000000..ac1faba1 --- /dev/null +++ b/app/Livewire/Storefront/Checkout/Success.php @@ -0,0 +1,38 @@ +orderNumber = request()->query('order'); + } + + public function render(): View + { + $order = null; + + if ($this->orderNumber !== null && $this->orderNumber !== '') { + $query = Order::query(); + + if (app()->bound('current_store')) { + $query->where('store_id', app('current_store')->getKey()); + } + + $order = $query->where('order_number', $this->orderNumber)->first(); + } + + return view('livewire.storefront.checkout.success', [ + 'order' => $order, + ]); + } +} diff --git a/app/Livewire/Storefront/Collections/Show.php b/app/Livewire/Storefront/Collections/Show.php new file mode 100644 index 00000000..8637f1a2 --- /dev/null +++ b/app/Livewire/Storefront/Collections/Show.php @@ -0,0 +1,45 @@ +handle = $handle; + $this->products = collect(); + + $this->collection = Collection::query() + ->where('handle', $handle) + ->first(); + + if ($this->collection !== null) { + $this->products = $this->collection + ->products() + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->with(['variants' => fn ($q) => $q->orderBy('position')->orderBy('id')]) + ->orderBy('title') + ->get(); + } + } + + public function render(): View + { + return view('livewire.storefront.collections.show'); + } +} diff --git a/app/Livewire/Storefront/Home.php b/app/Livewire/Storefront/Home.php new file mode 100644 index 00000000..e287f467 --- /dev/null +++ b/app/Livewire/Storefront/Home.php @@ -0,0 +1,53 @@ +bound('current_store') ? app('current_store') : null; + + $this->featuredCollection = Collection::query() + ->where('handle', 'featured') + ->first(); + + $this->featuredProducts = Product::query() + ->with(['variants' => fn ($q) => $q->orderBy('position')->orderBy('id'), 'media' => fn ($q) => $q->orderBy('position')]) + ->where('status', ProductStatus::Active) + ->whereNotNull('published_at') + ->orderByDesc('published_at') + ->limit(6) + ->get(); + + if ($store instanceof Store) { + $analytics->track( + $store, + AnalyticsEventType::PageView, + ['path' => '/'], + session()->getId(), + ); + } + } + + public function render(): View + { + return view('livewire.storefront.home'); + } +} diff --git a/app/Livewire/Storefront/Navigation.php b/app/Livewire/Storefront/Navigation.php new file mode 100644 index 00000000..91f9dbf9 --- /dev/null +++ b/app/Livewire/Storefront/Navigation.php @@ -0,0 +1,29 @@ +handle = $handle; + } + + public function render(): View + { + $menu = NavigationMenu::query() + ->where('handle', $this->handle) + ->with('items') + ->first(); + + return view('livewire.storefront.navigation', [ + 'menu' => $menu, + ]); + } +} diff --git a/app/Livewire/Storefront/Pages/Show.php b/app/Livewire/Storefront/Pages/Show.php new file mode 100644 index 00000000..664a51b9 --- /dev/null +++ b/app/Livewire/Storefront/Pages/Show.php @@ -0,0 +1,37 @@ +handle = $handle; + + $page = Page::query() + ->where('handle', $handle) + ->first(); + + if ($page === null || $page->status !== PageStatus::Published) { + abort(404); + } + + $this->page = $page; + } + + public function render(): View + { + return view('livewire.storefront.pages.show'); + } +} diff --git a/app/Livewire/Storefront/Products/Show.php b/app/Livewire/Storefront/Products/Show.php new file mode 100644 index 00000000..e73a0b89 --- /dev/null +++ b/app/Livewire/Storefront/Products/Show.php @@ -0,0 +1,94 @@ +handle = $handle; + + $this->product = Product::query() + ->with('variants') + ->where('handle', $handle) + ->first(); + + if ($this->product !== null) { + $firstVariant = $this->product->variants->first(); + $this->selected_variant_id = $firstVariant?->getKey(); + } + + $store = app()->bound('current_store') ? app('current_store') : null; + + if ($this->product !== null && $store instanceof Store) { + $analytics->track( + $store, + AnalyticsEventType::ProductView, + ['product_id' => $this->product->getKey(), 'handle' => $this->product->handle], + session()->getId(), + ); + } + } + + public function addToCart(CartService $cartService): void + { + $this->add_to_cart_error = ''; + + if ($this->selected_variant_id === null) { + $this->add_to_cart_error = 'Please select a variant.'; + + return; + } + + $store = app()->bound('current_store') ? app('current_store') : null; + + if (! $store instanceof Store) { + $this->add_to_cart_error = 'Store not found.'; + + return; + } + + $variant = ProductVariant::query()->find($this->selected_variant_id); + + if ($variant === null || (int) $variant->product?->store_id !== (int) $store->getKey()) { + $this->add_to_cart_error = 'Variant not available.'; + + return; + } + + try { + $cart = $cartService->getOrCreateForSession($store); + $cartService->addLine($cart, (int) $variant->getKey(), 1); + } catch (\Throwable $e) { + $this->add_to_cart_error = $e->getMessage(); + + return; + } + + $this->dispatch('cart-updated'); + } + + public function render(): View + { + return view('livewire.storefront.products.show'); + } +} diff --git a/app/Livewire/Storefront/Search/Index.php b/app/Livewire/Storefront/Search/Index.php new file mode 100644 index 00000000..7e8f7b94 --- /dev/null +++ b/app/Livewire/Storefront/Search/Index.php @@ -0,0 +1,33 @@ +bound('current_store') ? app('current_store') : null; + + /** @var Collection $results */ + $results = $store instanceof Store && trim($this->query) !== '' + ? $search->search($store, $this->query, [], session()->getId()) + : Product::hydrate([]); + + return view('livewire.storefront.search.index', [ + 'results' => $results, + ]); + } +} diff --git a/app/Models/AnalyticsDaily.php b/app/Models/AnalyticsDaily.php new file mode 100644 index 00000000..f404f924 --- /dev/null +++ b/app/Models/AnalyticsDaily.php @@ -0,0 +1,49 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + public $incrementing = false; + + protected $table = 'analytics_daily'; + + protected $primaryKey = null; + + protected $fillable = [ + 'store_id', + 'date', + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'orders_count' => 'integer', + 'revenue_amount' => 'integer', + 'aov_amount' => 'integer', + 'visits_count' => 'integer', + 'add_to_cart_count' => 'integer', + 'checkout_started_count' => 'integer', + 'checkout_completed_count' => 'integer', + ]; + } +} diff --git a/app/Models/AnalyticsEvent.php b/app/Models/AnalyticsEvent.php new file mode 100644 index 00000000..2447e526 --- /dev/null +++ b/app/Models/AnalyticsEvent.php @@ -0,0 +1,37 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'store_id', + 'type', + 'session_id', + 'customer_id', + 'properties_json', + 'client_event_id', + 'occurred_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'properties_json' => 'array', + 'occurred_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/App.php b/app/Models/App.php new file mode 100644 index 00000000..fab4b073 --- /dev/null +++ b/app/Models/App.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'name', + 'handle', + 'status', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'created_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function installations(): HasMany + { + return $this->hasMany(AppInstallation::class); + } +} diff --git a/app/Models/AppInstallation.php b/app/Models/AppInstallation.php new file mode 100644 index 00000000..33fe7fbf --- /dev/null +++ b/app/Models/AppInstallation.php @@ -0,0 +1,54 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'app_id', + 'scopes_json', + 'status', + 'installed_at', + 'uninstalled_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'scopes_json' => 'array', + 'installed_at' => 'datetime', + 'uninstalled_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function app(): BelongsTo + { + return $this->belongsTo(App::class); + } + + /** + * @return HasMany + */ + public function subscriptions(): HasMany + { + return $this->hasMany(WebhookSubscription::class); + } +} diff --git a/app/Models/Cart.php b/app/Models/Cart.php new file mode 100644 index 00000000..7da07e6f --- /dev/null +++ b/app/Models/Cart.php @@ -0,0 +1,69 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'customer_id', + 'currency', + 'cart_version', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'cart_version' => 'integer', + 'status' => CartStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(CartLine::class); + } + + /** + * @return HasMany + */ + public function checkouts(): HasMany + { + return $this->hasMany(Checkout::class); + } + + public function subtotal(): int + { + return (int) $this->lines()->sum('line_subtotal_amount'); + } + + public function totalQuantity(): int + { + return (int) $this->lines()->sum('quantity'); + } +} diff --git a/app/Models/CartLine.php b/app/Models/CartLine.php new file mode 100644 index 00000000..c8cceda5 --- /dev/null +++ b/app/Models/CartLine.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'cart_id', + 'variant_id', + 'quantity', + 'unit_price_amount', + 'line_subtotal_amount', + 'line_discount_amount', + 'line_total_amount', + ]; + + /** + * @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', + ]; + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } +} diff --git a/app/Models/Checkout.php b/app/Models/Checkout.php new file mode 100644 index 00000000..70b68c6c --- /dev/null +++ b/app/Models/Checkout.php @@ -0,0 +1,72 @@ + */ + use BelongsToStore, HasFactory; + + 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', + ]; + + /** + * @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', + ]; + } + + /** + * @return BelongsTo + */ + public function cart(): BelongsTo + { + return $this->belongsTo(Cart::class); + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return BelongsTo + */ + public function shippingRate(): BelongsTo + { + return $this->belongsTo(ShippingRate::class, 'shipping_method_id'); + } +} diff --git a/app/Models/Collection.php b/app/Models/Collection.php new file mode 100644 index 00000000..6d97f811 --- /dev/null +++ b/app/Models/Collection.php @@ -0,0 +1,46 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'description_html', + 'type', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => CollectionType::class, + 'status' => CollectionStatus::class, + ]; + } + + /** + * @return BelongsToMany + */ + public function products(): BelongsToMany + { + return $this->belongsToMany(Product::class, 'collection_products') + ->withPivot('position') + ->orderBy('collection_products.position'); + } +} diff --git a/app/Models/Concerns/BelongsToStore.php b/app/Models/Concerns/BelongsToStore.php new file mode 100644 index 00000000..70fca070 --- /dev/null +++ b/app/Models/Concerns/BelongsToStore.php @@ -0,0 +1,36 @@ +store_id === null && app()->bound('current_store')) { + $store = app('current_store'); + + if ($store instanceof Store) { + $model->store_id = $store->getKey(); + } + } + }); + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php new file mode 100644 index 00000000..8dcd0776 --- /dev/null +++ b/app/Models/Customer.php @@ -0,0 +1,89 @@ + */ + use BelongsToStore, CanResetPassword, HasApiTokens, HasFactory, Notifiable; + + /** + * @var list + */ + protected $fillable = [ + 'store_id', + 'email', + 'password_hash', + 'name', + 'marketing_opt_in', + 'email_verified_at', + ]; + + /** + * @var list + */ + protected $hidden = [ + 'password_hash', + 'remember_token', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'marketing_opt_in' => 'boolean', + ]; + } + + public function getAuthPassword(): string + { + return (string) $this->password_hash; + } + + /** + * @return HasMany + */ + public function addresses(): HasMany + { + return $this->hasMany(CustomerAddress::class); + } + + /** + * @return HasMany + */ + public function carts(): HasMany + { + return $this->hasMany(Cart::class); + } + + /** + * @return HasMany + */ + public function orders(): HasMany + { + return $this->hasMany(Order::class); + } + + public function hasPassword(): bool + { + return $this->password_hash !== null && $this->password_hash !== ''; + } + + public function sendPasswordResetNotification(#[\SensitiveParameter] $token): void + { + $this->notify(new CustomerResetPasswordNotification($token)); + } +} diff --git a/app/Models/CustomerAddress.php b/app/Models/CustomerAddress.php new file mode 100644 index 00000000..3bb7b4f2 --- /dev/null +++ b/app/Models/CustomerAddress.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'customer_id', + 'label', + 'address_json', + 'is_default', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'address_json' => 'array', + 'is_default' => 'boolean', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } +} diff --git a/app/Models/Discount.php b/app/Models/Discount.php new file mode 100644 index 00000000..1438db96 --- /dev/null +++ b/app/Models/Discount.php @@ -0,0 +1,57 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'type', + 'code', + 'value_type', + 'value_amount', + 'starts_at', + 'ends_at', + 'usage_limit', + 'usage_count', + 'rules_json', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => DiscountType::class, + 'value_type' => DiscountValueType::class, + 'status' => DiscountStatus::class, + 'value_amount' => 'integer', + 'usage_limit' => 'integer', + 'usage_count' => 'integer', + 'rules_json' => 'array', + 'starts_at' => 'datetime', + 'ends_at' => 'datetime', + ]; + } + + public function hasUsageRemaining(): bool + { + if ($this->usage_limit === null) { + return true; + } + + return $this->usage_count < $this->usage_limit; + } +} diff --git a/app/Models/Fulfillment.php b/app/Models/Fulfillment.php new file mode 100644 index 00000000..444933dc --- /dev/null +++ b/app/Models/Fulfillment.php @@ -0,0 +1,55 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'status', + 'tracking_company', + 'tracking_number', + 'tracking_url', + 'shipped_at', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => FulfillmentShipmentStatus::class, + 'shipped_at' => 'datetime', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @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..b7f3d5d4 --- /dev/null +++ b/app/Models/FulfillmentLine.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'fulfillment_id', + 'order_line_id', + 'quantity', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function fulfillment(): BelongsTo + { + return $this->belongsTo(Fulfillment::class); + } + + /** + * @return BelongsTo + */ + public function orderLine(): BelongsTo + { + return $this->belongsTo(OrderLine::class); + } +} diff --git a/app/Models/InventoryItem.php b/app/Models/InventoryItem.php new file mode 100644 index 00000000..1e64813f --- /dev/null +++ b/app/Models/InventoryItem.php @@ -0,0 +1,50 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'variant_id', + 'quantity_on_hand', + 'quantity_reserved', + 'policy', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity_on_hand' => 'integer', + 'quantity_reserved' => 'integer', + 'policy' => InventoryPolicy::class, + ]; + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + public function available(): int + { + return $this->quantity_on_hand - $this->quantity_reserved; + } +} diff --git a/app/Models/NavigationItem.php b/app/Models/NavigationItem.php new file mode 100644 index 00000000..ceff9a3c --- /dev/null +++ b/app/Models/NavigationItem.php @@ -0,0 +1,72 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'menu_id', + 'type', + 'label', + 'url', + 'resource_id', + 'position', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => NavigationItemType::class, + 'position' => 'integer', + 'resource_id' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function menu(): BelongsTo + { + return $this->belongsTo(NavigationMenu::class, 'menu_id'); + } + + public function resolveUrl(): string + { + return match ($this->type) { + NavigationItemType::Link => (string) ($this->url ?? '#'), + NavigationItemType::Page => $this->resource_id !== null + ? $this->resolvePageUrl() + : '#', + NavigationItemType::Collection => $this->resource_id !== null + ? '/collections/'.$this->resource_id + : '#', + NavigationItemType::Product => $this->resource_id !== null + ? '/products/'.$this->resource_id + : '#', + }; + } + + protected function resolvePageUrl(): string + { + $page = Page::query()->find($this->resource_id); + + if ($page === null) { + return '#'; + } + + return '/pages/'.$page->handle; + } +} diff --git a/app/Models/NavigationMenu.php b/app/Models/NavigationMenu.php new file mode 100644 index 00000000..105096a2 --- /dev/null +++ b/app/Models/NavigationMenu.php @@ -0,0 +1,28 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'handle', + 'title', + ]; + + /** + * @return HasMany + */ + public function items(): HasMany + { + return $this->hasMany(NavigationItem::class, 'menu_id')->orderBy('position'); + } +} diff --git a/app/Models/Order.php b/app/Models/Order.php new file mode 100644 index 00000000..5b6923c0 --- /dev/null +++ b/app/Models/Order.php @@ -0,0 +1,110 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_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', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => OrderStatus::class, + 'financial_status' => FinancialStatus::class, + 'fulfillment_status' => FulfillmentStatus::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', + ]; + } + + /** + * @return BelongsTo + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * @return HasMany + */ + public function lines(): HasMany + { + return $this->hasMany(OrderLine::class); + } + + /** + * @return HasMany + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } + + /** + * @return HasMany + */ + public function fulfillments(): HasMany + { + return $this->hasMany(Fulfillment::class); + } + + public function totalRefunded(): int + { + return (int) $this->refunds()->where('status', \App\Enums\RefundStatus::Processed->value)->sum('amount'); + } + + public function remainingRefundable(): int + { + return max(0, $this->total_amount - $this->totalRefunded()); + } +} diff --git a/app/Models/OrderLine.php b/app/Models/OrderLine.php new file mode 100644 index 00000000..e1051c70 --- /dev/null +++ b/app/Models/OrderLine.php @@ -0,0 +1,85 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'product_id', + 'variant_id', + 'title_snapshot', + 'sku_snapshot', + 'quantity', + 'unit_price_amount', + 'total_amount', + 'tax_lines_json', + 'discount_allocations_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'quantity' => 'integer', + 'unit_price_amount' => 'integer', + 'total_amount' => 'integer', + 'tax_lines_json' => 'array', + 'discount_allocations_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsTo + */ + public function variant(): BelongsTo + { + return $this->belongsTo(ProductVariant::class, 'variant_id'); + } + + /** + * @return HasMany + */ + public function fulfillmentLines(): HasMany + { + return $this->hasMany(FulfillmentLine::class); + } + + public function fulfilledQuantity(): int + { + return (int) $this->fulfillmentLines()->sum('quantity'); + } + + public function unfulfilledQuantity(): int + { + return max(0, $this->quantity - $this->fulfilledQuantity()); + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php new file mode 100644 index 00000000..457450e6 --- /dev/null +++ b/app/Models/Organization.php @@ -0,0 +1,34 @@ + */ + use HasFactory; + + protected $fillable = [ + 'name', + 'billing_email', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return []; + } + + /** + * @return HasMany + */ + public function stores(): HasMany + { + return $this->hasMany(Store::class); + } +} diff --git a/app/Models/Page.php b/app/Models/Page.php new file mode 100644 index 00000000..00cf0f36 --- /dev/null +++ b/app/Models/Page.php @@ -0,0 +1,39 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'body_html', + 'status', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PageStatus::class, + 'published_at' => 'datetime', + ]; + } + + public function isPublished(): bool + { + return $this->status === PageStatus::Published; + } +} diff --git a/app/Models/Payment.php b/app/Models/Payment.php new file mode 100644 index 00000000..7728b858 --- /dev/null +++ b/app/Models/Payment.php @@ -0,0 +1,60 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'provider', + 'method', + 'provider_payment_id', + 'status', + 'amount', + 'currency', + 'raw_json_encrypted', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => PaymentStatus::class, + 'method' => PaymentMethod::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + 'raw_json_encrypted' => 'encrypted:array', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return HasMany + */ + public function refunds(): HasMany + { + return $this->hasMany(Refund::class); + } +} diff --git a/app/Models/Product.php b/app/Models/Product.php new file mode 100644 index 00000000..6a97818f --- /dev/null +++ b/app/Models/Product.php @@ -0,0 +1,78 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'title', + 'handle', + 'status', + 'description_html', + 'vendor', + 'product_type', + 'tags', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ProductStatus::class, + 'tags' => 'array', + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function options(): HasMany + { + return $this->hasMany(ProductOption::class)->orderBy('position'); + } + + /** + * @return HasMany + */ + public function variants(): HasMany + { + return $this->hasMany(ProductVariant::class)->orderBy('position'); + } + + /** + * @return HasMany + */ + public function media(): HasMany + { + return $this->hasMany(ProductMedia::class)->orderBy('position'); + } + + /** + * @return BelongsToMany + */ + public function collections(): BelongsToMany + { + return $this->belongsToMany(Collection::class, 'collection_products') + ->withPivot('position'); + } + + public function isPublished(): bool + { + return $this->status === ProductStatus::Active && $this->published_at !== null; + } +} diff --git a/app/Models/ProductMedia.php b/app/Models/ProductMedia.php new file mode 100644 index 00000000..979849b8 --- /dev/null +++ b/app/Models/ProductMedia.php @@ -0,0 +1,56 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $table = 'product_media'; + + protected $fillable = [ + 'product_id', + 'type', + 'storage_key', + 'alt_text', + 'width', + 'height', + 'mime_type', + 'byte_size', + 'position', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => MediaType::class, + 'status' => MediaStatus::class, + 'width' => 'integer', + 'height' => 'integer', + 'byte_size' => 'integer', + 'position' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/app/Models/ProductOption.php b/app/Models/ProductOption.php new file mode 100644 index 00000000..b61be7c4 --- /dev/null +++ b/app/Models/ProductOption.php @@ -0,0 +1,48 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_id', + 'name', + 'position', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @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..8f2dff5a --- /dev/null +++ b/app/Models/ProductOptionValue.php @@ -0,0 +1,53 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'product_option_id', + 'value', + 'position', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'position' => 'integer', + ]; + } + + /** + * @return BelongsTo + */ + public function option(): BelongsTo + { + return $this->belongsTo(ProductOption::class, 'product_option_id'); + } + + /** + * @return BelongsToMany + */ + public function variants(): BelongsToMany + { + return $this->belongsToMany( + ProductVariant::class, + 'variant_option_values', + 'product_option_value_id', + 'variant_id' + ); + } +} diff --git a/app/Models/ProductVariant.php b/app/Models/ProductVariant.php new file mode 100644 index 00000000..43c582b1 --- /dev/null +++ b/app/Models/ProductVariant.php @@ -0,0 +1,75 @@ + */ + use HasFactory; + + protected $fillable = [ + 'product_id', + 'sku', + 'barcode', + 'price_amount', + 'compare_at_amount', + 'currency', + 'weight_g', + 'requires_shipping', + 'is_default', + 'position', + 'status', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'price_amount' => 'integer', + 'compare_at_amount' => 'integer', + 'weight_g' => 'integer', + 'requires_shipping' => 'boolean', + 'is_default' => 'boolean', + 'position' => 'integer', + 'status' => VariantStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * @return BelongsToMany + */ + public function optionValues(): BelongsToMany + { + return $this->belongsToMany( + ProductOptionValue::class, + 'variant_option_values', + 'variant_id', + 'product_option_value_id' + ); + } + + /** + * @return HasOne + */ + public function inventoryItem(): HasOne + { + return $this->hasOne(InventoryItem::class, 'variant_id'); + } +} diff --git a/app/Models/Refund.php b/app/Models/Refund.php new file mode 100644 index 00000000..bf1f59d8 --- /dev/null +++ b/app/Models/Refund.php @@ -0,0 +1,54 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'order_id', + 'payment_id', + 'amount', + 'reason', + 'status', + 'provider_refund_id', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => RefundStatus::class, + 'amount' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function order(): BelongsTo + { + return $this->belongsTo(Order::class); + } + + /** + * @return BelongsTo + */ + public function payment(): BelongsTo + { + return $this->belongsTo(Payment::class); + } +} diff --git a/app/Models/Scopes/StoreScope.php b/app/Models/Scopes/StoreScope.php new file mode 100644 index 00000000..462457df --- /dev/null +++ b/app/Models/Scopes/StoreScope.php @@ -0,0 +1,29 @@ + $builder + */ + public function apply(Builder $builder, Model $model): void + { + if (! app()->bound('current_store')) { + return; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return; + } + + $builder->where($model->qualifyColumn('store_id'), $store->getKey()); + } +} diff --git a/app/Models/SearchQuery.php b/app/Models/SearchQuery.php new file mode 100644 index 00000000..d0aa898e --- /dev/null +++ b/app/Models/SearchQuery.php @@ -0,0 +1,35 @@ + */ + use BelongsToStore, HasFactory; + + public const UPDATED_AT = null; + + protected $fillable = [ + 'store_id', + 'query', + 'filters_json', + 'results_count', + 'session_id', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'filters_json' => 'array', + 'results_count' => 'integer', + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/ShippingRate.php b/app/Models/ShippingRate.php new file mode 100644 index 00000000..2b2d69cb --- /dev/null +++ b/app/Models/ShippingRate.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'zone_id', + 'name', + 'type', + 'config_json', + 'is_active', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => ShippingRateType::class, + 'config_json' => 'array', + 'is_active' => 'boolean', + ]; + } + + /** + * @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..4e5d3da1 --- /dev/null +++ b/app/Models/ShippingZone.php @@ -0,0 +1,42 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'name', + 'countries_json', + 'regions_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'countries_json' => 'array', + 'regions_json' => 'array', + ]; + } + + /** + * @return HasMany + */ + public function rates(): HasMany + { + return $this->hasMany(ShippingRate::class, 'zone_id'); + } +} diff --git a/app/Models/Store.php b/app/Models/Store.php new file mode 100644 index 00000000..9816ce9c --- /dev/null +++ b/app/Models/Store.php @@ -0,0 +1,76 @@ + */ + use HasFactory; + + protected $fillable = [ + 'organization_id', + 'name', + 'handle', + 'status', + 'default_currency', + 'default_locale', + 'timezone', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => StoreStatus::class, + ]; + } + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class); + } + + /** + * @return HasMany + */ + public function domains(): HasMany + { + return $this->hasMany(StoreDomain::class); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(StoreSettings::class); + } + + /** + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function isSuspended(): bool + { + return $this->status === StoreStatus::Suspended; + } +} diff --git a/app/Models/StoreDomain.php b/app/Models/StoreDomain.php new file mode 100644 index 00000000..61fc195b --- /dev/null +++ b/app/Models/StoreDomain.php @@ -0,0 +1,44 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'hostname', + 'type', + 'is_primary', + 'tls_mode', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => StoreDomainType::class, + 'is_primary' => 'boolean', + 'created_at' => 'datetime', + ]; + } + + /** + * @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..45234fd8 --- /dev/null +++ b/app/Models/StoreSettings.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + protected $table = 'store_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + const CREATED_AT = null; + + protected $fillable = [ + 'store_id', + 'settings_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + /** + * @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..aef39b8a --- /dev/null +++ b/app/Models/StoreUser.php @@ -0,0 +1,33 @@ + + */ + protected function casts(): array + { + return [ + 'role' => StoreUserRole::class, + 'created_at' => 'datetime', + ]; + } +} diff --git a/app/Models/TaxSettings.php b/app/Models/TaxSettings.php new file mode 100644 index 00000000..4b7c32d2 --- /dev/null +++ b/app/Models/TaxSettings.php @@ -0,0 +1,52 @@ + */ + use HasFactory; + + protected $table = 'tax_settings'; + + protected $primaryKey = 'store_id'; + + public $incrementing = false; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'mode', + 'provider', + 'prices_include_tax', + 'config_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'mode' => TaxMode::class, + 'provider' => TaxProviderType::class, + 'prices_include_tax' => 'boolean', + 'config_json' => 'array', + ]; + } + + /** + * @return BelongsTo + */ + public function store(): BelongsTo + { + return $this->belongsTo(Store::class); + } +} diff --git a/app/Models/Theme.php b/app/Models/Theme.php new file mode 100644 index 00000000..a30e6a6d --- /dev/null +++ b/app/Models/Theme.php @@ -0,0 +1,51 @@ + */ + use BelongsToStore, HasFactory; + + protected $fillable = [ + 'store_id', + 'name', + 'version', + 'status', + 'published_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'status' => ThemeStatus::class, + 'published_at' => 'datetime', + ]; + } + + /** + * @return HasMany + */ + public function files(): HasMany + { + return $this->hasMany(ThemeFile::class); + } + + /** + * @return HasOne + */ + public function settings(): HasOne + { + return $this->hasOne(ThemeSettings::class); + } +} diff --git a/app/Models/ThemeFile.php b/app/Models/ThemeFile.php new file mode 100644 index 00000000..e5b1ce97 --- /dev/null +++ b/app/Models/ThemeFile.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'theme_id', + 'path', + 'storage_key', + 'sha256', + 'byte_size', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'byte_size' => 'integer', + ]; + } + + /** + * @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..727421d5 --- /dev/null +++ b/app/Models/ThemeSettings.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + protected $table = 'theme_settings'; + + protected $primaryKey = 'theme_id'; + + public $incrementing = false; + + const CREATED_AT = null; + + protected $fillable = [ + 'theme_id', + 'settings_json', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'settings_json' => 'array', + 'updated_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function theme(): BelongsTo + { + return $this->belongsTo(Theme::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 214bea4e..9bce2f3e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,32 +2,32 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\StoreUserRole; 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 { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** - * The attributes that are mass assignable. - * * @var list */ protected $fillable = [ 'name', 'email', 'password', + 'status', + 'last_login_at', ]; /** - * The attributes that should be hidden for serialization. - * * @var list */ protected $hidden = [ @@ -38,27 +38,48 @@ class User extends Authenticatable ]; /** - * Get the attributes that should be cast. - * * @return array */ protected function casts(): array { return [ 'email_verified_at' => 'datetime', + 'last_login_at' => 'datetime', 'password' => 'hashed', ]; } /** - * Get the user's initials + * @return BelongsToMany */ + public function stores(): BelongsToMany + { + return $this->belongsToMany(Store::class, 'store_users') + ->using(StoreUser::class) + ->withPivot('role', 'created_at'); + } + + public function roleForStore(Store $store): ?StoreUserRole + { + $pivot = $this->stores() + ->wherePivot('store_id', $store->getKey()) + ->first()?->pivot; + + if ($pivot === null) { + return null; + } + + $role = $pivot->role; + + return $role instanceof StoreUserRole ? $role : StoreUserRole::from((string) $role); + } + public function initials(): string { return Str::of($this->name) ->explode(' ') ->take(2) - ->map(fn ($word) => Str::substr($word, 0, 1)) + ->map(fn (string $word): string => Str::substr($word, 0, 1)) ->implode(''); } } diff --git a/app/Models/WebhookDelivery.php b/app/Models/WebhookDelivery.php new file mode 100644 index 00000000..6255d716 --- /dev/null +++ b/app/Models/WebhookDelivery.php @@ -0,0 +1,47 @@ + */ + use HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'subscription_id', + 'event_id', + 'attempt_count', + 'status', + 'last_attempt_at', + 'next_retry_at', + 'response_code', + 'response_body_snippet', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'attempt_count' => 'integer', + 'response_code' => 'integer', + 'last_attempt_at' => 'datetime', + 'next_retry_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function subscription(): BelongsTo + { + return $this->belongsTo(WebhookSubscription::class, 'subscription_id'); + } +} diff --git a/app/Models/WebhookSubscription.php b/app/Models/WebhookSubscription.php new file mode 100644 index 00000000..1524f1c4 --- /dev/null +++ b/app/Models/WebhookSubscription.php @@ -0,0 +1,61 @@ + */ + use BelongsToStore, HasFactory; + + public $timestamps = false; + + protected $fillable = [ + 'store_id', + 'app_installation_id', + 'event_type', + 'target_url', + 'signing_secret_encrypted', + 'status', + 'consecutive_failures', + 'created_at', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'signing_secret_encrypted' => 'encrypted', + 'consecutive_failures' => 'integer', + 'created_at' => 'datetime', + ]; + } + + /** + * @return BelongsTo + */ + public function installation(): BelongsTo + { + return $this->belongsTo(AppInstallation::class, 'app_installation_id'); + } + + /** + * @return HasMany + */ + public function deliveries(): HasMany + { + return $this->hasMany(WebhookDelivery::class, 'subscription_id'); + } + + public function isActive(): bool + { + return $this->status === 'active'; + } +} diff --git a/app/Notifications/CustomerResetPasswordNotification.php b/app/Notifications/CustomerResetPasswordNotification.php new file mode 100644 index 00000000..9720f5c2 --- /dev/null +++ b/app/Notifications/CustomerResetPasswordNotification.php @@ -0,0 +1,33 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $url = url('/account/reset-password/'.$this->token.'?email='.urlencode((string) $notifiable->email)); + + return (new MailMessage) + ->subject('Reset your password') + ->line('You are receiving this email because we received a password reset request for your account.') + ->action('Reset password', $url) + ->line('If you did not request a password reset no further action is required.'); + } +} diff --git a/app/Notifications/CustomerWelcomeNotification.php b/app/Notifications/CustomerWelcomeNotification.php new file mode 100644 index 00000000..cefbe775 --- /dev/null +++ b/app/Notifications/CustomerWelcomeNotification.php @@ -0,0 +1,46 @@ + + */ + public function via(object $notifiable): array + { + return ['mail']; + } + + public function toMail(object $notifiable): MailMessage + { + $token = $this->createToken($notifiable); + + $url = url('/account/set-password?token='.$token.'&email='.urlencode((string) $notifiable->email)); + + return (new MailMessage) + ->subject('Welcome to your account') + ->line('Thanks for your order. We have created an account for you.') + ->action('Set your password', $url) + ->line('If you did not expect this email you can ignore it.'); + } + + protected function createToken(object $notifiable): string + { + if (! $notifiable instanceof Customer) { + return ''; + } + + return Password::broker('customers')->createToken($notifiable); + } +} diff --git a/app/Observers/CustomerObserver.php b/app/Observers/CustomerObserver.php new file mode 100644 index 00000000..10d6ba49 --- /dev/null +++ b/app/Observers/CustomerObserver.php @@ -0,0 +1,14 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, object $collection): bool + { + return $this->isAnyRole($user, (int) $collection->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, object $collection): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $collection->store_id); + } + + public function delete(User $user, object $collection): bool + { + return $this->isOwnerOrAdmin($user, (int) $collection->store_id); + } +} diff --git a/app/Policies/CustomerPolicy.php b/app/Policies/CustomerPolicy.php new file mode 100644 index 00000000..f6d14adf --- /dev/null +++ b/app/Policies/CustomerPolicy.php @@ -0,0 +1,28 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, object $customer): bool + { + return $this->isAnyRole($user, (int) $customer->store_id); + } + + public function update(User $user, object $customer): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $customer->store_id); + } +} diff --git a/app/Policies/DiscountPolicy.php b/app/Policies/DiscountPolicy.php new file mode 100644 index 00000000..9dee201b --- /dev/null +++ b/app/Policies/DiscountPolicy.php @@ -0,0 +1,40 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, object $discount): bool + { + return $this->isAnyRole($user, (int) $discount->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, object $discount): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $discount->store_id); + } + + public function delete(User $user, object $discount): bool + { + return $this->isOwnerOrAdmin($user, (int) $discount->store_id); + } +} diff --git a/app/Policies/FulfillmentPolicy.php b/app/Policies/FulfillmentPolicy.php new file mode 100644 index 00000000..6165d9d8 --- /dev/null +++ b/app/Policies/FulfillmentPolicy.php @@ -0,0 +1,38 @@ +isOwnerAdminOrStaff($user, (int) $order->store_id); + } + + public function update(User $user, object $fulfillment): bool + { + $storeId = $fulfillment->order->store_id ?? $fulfillment->store_id ?? null; + + if ($storeId === null) { + return false; + } + + return $this->isOwnerAdminOrStaff($user, (int) $storeId); + } + + public function cancel(User $user, object $fulfillment): bool + { + $storeId = $fulfillment->order->store_id ?? $fulfillment->store_id ?? null; + + if ($storeId === null) { + return false; + } + + return $this->isOwnerAdminOrStaff($user, (int) $storeId); + } +} diff --git a/app/Policies/OrderPolicy.php b/app/Policies/OrderPolicy.php new file mode 100644 index 00000000..5152c38a --- /dev/null +++ b/app/Policies/OrderPolicy.php @@ -0,0 +1,43 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, object $order): bool + { + return $this->isAnyRole($user, (int) $order->store_id); + } + + public function update(User $user, object $order): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $order->store_id); + } + + public function cancel(User $user, object $order): bool + { + return $this->isOwnerOrAdmin($user, (int) $order->store_id); + } + + public function createFulfillment(User $user, object $order): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $order->store_id); + } + + public function createRefund(User $user, object $order): bool + { + return $this->isOwnerOrAdmin($user, (int) $order->store_id); + } +} diff --git a/app/Policies/PagePolicy.php b/app/Policies/PagePolicy.php new file mode 100644 index 00000000..d8a65dd6 --- /dev/null +++ b/app/Policies/PagePolicy.php @@ -0,0 +1,40 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function view(User $user, object $page): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $page->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, object $page): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $page->store_id); + } + + public function delete(User $user, object $page): bool + { + return $this->isOwnerOrAdmin($user, (int) $page->store_id); + } +} diff --git a/app/Policies/ProductPolicy.php b/app/Policies/ProductPolicy.php new file mode 100644 index 00000000..d51d0c23 --- /dev/null +++ b/app/Policies/ProductPolicy.php @@ -0,0 +1,50 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isAnyRole($user, $storeId); + } + + public function view(User $user, object $product): bool + { + return $this->isAnyRole($user, (int) $product->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerAdminOrStaff($user, $storeId); + } + + public function update(User $user, object $product): bool + { + return $this->isOwnerAdminOrStaff($user, (int) $product->store_id); + } + + public function delete(User $user, object $product): bool + { + return $this->isOwnerOrAdmin($user, (int) $product->store_id); + } + + public function archive(User $user, object $product): bool + { + return $this->isOwnerOrAdmin($user, (int) $product->store_id); + } + + public function restore(User $user, object $product): bool + { + return $this->isOwnerOrAdmin($user, (int) $product->store_id); + } +} diff --git a/app/Policies/RefundPolicy.php b/app/Policies/RefundPolicy.php new file mode 100644 index 00000000..6d733651 --- /dev/null +++ b/app/Policies/RefundPolicy.php @@ -0,0 +1,16 @@ +isOwnerOrAdmin($user, (int) $order->store_id); + } +} diff --git a/app/Policies/StorePolicy.php b/app/Policies/StorePolicy.php new file mode 100644 index 00000000..9a14d679 --- /dev/null +++ b/app/Policies/StorePolicy.php @@ -0,0 +1,27 @@ +isOwnerOrAdmin($user, (int) $store->getKey()); + } + + public function updateSettings(User $user, Store $store): bool + { + return $this->isOwnerOrAdmin($user, (int) $store->getKey()); + } + + public function delete(User $user, Store $store): bool + { + return $this->hasRole($user, (int) $store->getKey(), [\App\Enums\StoreUserRole::Owner]); + } +} diff --git a/app/Policies/ThemePolicy.php b/app/Policies/ThemePolicy.php new file mode 100644 index 00000000..e2c5d852 --- /dev/null +++ b/app/Policies/ThemePolicy.php @@ -0,0 +1,45 @@ +resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function view(User $user, object $theme): bool + { + return $this->isOwnerOrAdmin($user, (int) $theme->store_id); + } + + public function create(User $user): bool + { + $storeId = $this->resolveCurrentStoreId(); + + return $storeId !== null && $this->isOwnerOrAdmin($user, $storeId); + } + + public function update(User $user, object $theme): bool + { + return $this->isOwnerOrAdmin($user, (int) $theme->store_id); + } + + public function delete(User $user, object $theme): bool + { + return $this->isOwnerOrAdmin($user, (int) $theme->store_id); + } + + public function publish(User $user, object $theme): bool + { + return $this->isOwnerOrAdmin($user, (int) $theme->store_id); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8a29e6f5..b4853665 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,33 +2,78 @@ namespace App\Providers; +use App\Auth\CustomerTokenRepository; +use App\Auth\CustomerUserProvider; use Carbon\CarbonImmutable; +use Illuminate\Auth\Passwords\PasswordBroker; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Http\Request; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\ServiceProvider; -use Illuminate\Validation\Rules\Password; +use Illuminate\Validation\Rules\Password as PasswordRule; +use Livewire\Livewire; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->singleton(\App\Contracts\PaymentProvider::class, \App\Services\Payments\MockPaymentProvider::class); + + $this->app->extend('auth.password', function (mixed $manager, mixed $app): mixed { + return new class($app) extends \Illuminate\Auth\Passwords\PasswordBrokerManager + { + protected function resolve($name) + { + if ($name === 'customers') { + $config = $this->app['config']['auth.passwords.customers']; + $key = (string) $this->app['config']['app.key']; + + if (str_starts_with($key, 'base64:')) { + $key = base64_decode(substr($key, 7)); + } + + $repository = new CustomerTokenRepository( + $this->app['db']->connection(), + $this->app['hash'], + (string) $config['table'], + $key, + ((int) ($config['expire'] ?? 60)) * 60, + (int) ($config['throttle'] ?? 0), + ); + + return new PasswordBroker( + $repository, + $this->app['auth']->createUserProvider('customers'), + $this->app['events'] ?? null, + ); + } + + return parent::resolve($name); + } + }; + }); } - /** - * Bootstrap any application services. - */ public function boot(): void { $this->configureDefaults(); + $this->configureAuth(); + $this->configureRateLimiters(); + $this->configureLivewire(); + } + + protected function configureLivewire(): void + { + Livewire::setUpdateRoute(function ($handle) { + return \Illuminate\Support\Facades\Route::post(config('livewire.update_path', '/livewire/update'), $handle) + ->middleware('web', 'store.resolve:storefront'); + }); } - /** - * Configure default behaviors for production-ready applications. - */ protected function configureDefaults(): void { Date::use(CarbonImmutable::class); @@ -37,8 +82,8 @@ protected function configureDefaults(): void app()->isProduction(), ); - Password::defaults(fn (): ?Password => app()->isProduction() - ? Password::min(12) + PasswordRule::defaults(fn (): ?PasswordRule => app()->isProduction() + ? PasswordRule::min(12) ->mixedCase() ->letters() ->numbers() @@ -47,4 +92,32 @@ protected function configureDefaults(): void : null ); } + + protected function configureAuth(): void + { + Auth::provider('customer', function (mixed $app, array $config): CustomerUserProvider { + return new CustomerUserProvider(Hash::driver(), \App\Models\Customer::class); + }); + } + + protected function configureRateLimiters(): void + { + RateLimiter::for('api.admin', fn (Request $request): Limit => Limit::perMinute(60) + ->by((string) ($request->user()?->getAuthIdentifier() ?? $request->ip()))); + + RateLimiter::for('api.storefront', fn (Request $request): Limit => Limit::perMinute(120) + ->by((string) $request->ip())); + + RateLimiter::for('checkout', fn (Request $request): Limit => Limit::perMinute(10) + ->by((string) $request->session()->getId())); + + RateLimiter::for('search', fn (Request $request): Limit => Limit::perMinute(30) + ->by((string) $request->ip())); + + RateLimiter::for('analytics', fn (Request $request): Limit => Limit::perMinute(60) + ->by((string) $request->ip())); + + RateLimiter::for('webhooks', fn (Request $request): Limit => Limit::perMinute(100) + ->by((string) $request->ip())); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 00000000..1b29f010 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,34 @@ + $properties + */ + public function track( + Store $store, + AnalyticsEventType|string $type, + array $properties = [], + ?string $sessionId = null, + ?int $customerId = null, + ?string $clientEventId = null, + ): ?AnalyticsEvent { + $typeValue = $type instanceof AnalyticsEventType ? $type->value : $type; + + $attributes = [ + 'store_id' => $store->getKey(), + 'type' => $typeValue, + 'session_id' => $sessionId, + 'customer_id' => $customerId, + 'properties_json' => json_encode($properties === [] ? (object) [] : $properties, JSON_THROW_ON_ERROR), + 'client_event_id' => $clientEventId, + 'occurred_at' => now(), + 'created_at' => now(), + ]; + + if ($clientEventId !== null) { + $inserted = DB::table('analytics_events')->insertOrIgnore($attributes); + + if ($inserted === 0) { + return null; + } + + return AnalyticsEvent::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('client_event_id', $clientEventId) + ->first(); + } + + $id = DB::table('analytics_events')->insertGetId($attributes); + + return AnalyticsEvent::query() + ->withoutGlobalScopes() + ->find($id); + } +} diff --git a/app/Services/CartService.php b/app/Services/CartService.php new file mode 100644 index 00000000..5c32a83e --- /dev/null +++ b/app/Services/CartService.php @@ -0,0 +1,241 @@ +create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer?->getKey(), + 'currency' => $store->default_currency, + 'cart_version' => 1, + 'status' => CartStatus::Active->value, + ]); + } + + public function getOrCreateForSession(Store $store, ?Customer $customer = null): Cart + { + $sessionCartId = session('cart_id'); + + if ($sessionCartId !== null) { + $cart = Cart::query()->find($sessionCartId); + + if ($cart !== null && $cart->status === CartStatus::Active && (int) $cart->store_id === (int) $store->getKey()) { + if ($customer !== null && $cart->customer_id === null) { + $cart->customer_id = $customer->getKey(); + $cart->save(); + } + + return $cart; + } + } + + $cart = $this->create($store, $customer); + session(['cart_id' => $cart->getKey()]); + + return $cart; + } + + public function addLine(Cart $cart, int $variantId, int $quantity, ?int $expectedVersion = null): CartLine + { + if ($quantity < 1) { + throw new RuntimeException('Quantity must be at least 1.'); + } + + return DB::transaction(function () use ($cart, $variantId, $quantity, $expectedVersion): CartLine { + $cart = $this->lockCart($cart); + $this->assertVersion($cart, $expectedVersion); + + $variant = ProductVariant::query()->with('product')->findOrFail($variantId); + + if ($variant->product === null || (int) $variant->product->store_id !== (int) $cart->store_id) { + throw new RuntimeException('Variant does not belong to this store.'); + } + + if ($variant->product->status !== ProductStatus::Active) { + throw new RuntimeException('Product is not active.'); + } + + if ($variant->status !== VariantStatus::Active) { + throw new RuntimeException('Variant is not active.'); + } + + $existing = CartLine::query() + ->where('cart_id', $cart->getKey()) + ->where('variant_id', $variant->getKey()) + ->first(); + + $targetQuantity = ($existing?->quantity ?? 0) + $quantity; + + if (! $this->inventory->checkAvailability($variant, $targetQuantity)) { + throw new InsufficientInventoryException($variant->getKey(), $targetQuantity, 0); + } + + if ($existing !== null) { + $existing->quantity = $targetQuantity; + $existing->unit_price_amount = (int) $variant->price_amount; + $this->recalculateLine($existing); + $existing->save(); + $line = $existing; + } else { + $price = (int) $variant->price_amount; + $line = CartLine::query()->create([ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => $quantity, + 'unit_price_amount' => $price, + 'line_subtotal_amount' => $price * $quantity, + 'line_discount_amount' => 0, + 'line_total_amount' => $price * $quantity, + ]); + } + + $this->touchVersion($cart); + + return $line->refresh(); + }); + } + + public function updateLineQuantity(Cart $cart, int $lineId, int $quantity, ?int $expectedVersion = null): ?CartLine + { + return DB::transaction(function () use ($cart, $lineId, $quantity, $expectedVersion): ?CartLine { + $cart = $this->lockCart($cart); + $this->assertVersion($cart, $expectedVersion); + + $line = CartLine::query() + ->where('cart_id', $cart->getKey()) + ->where('id', $lineId) + ->firstOrFail(); + + if ($quantity <= 0) { + $line->delete(); + $this->touchVersion($cart); + + return null; + } + + $variant = ProductVariant::query()->findOrFail($line->variant_id); + + if (! $this->inventory->checkAvailability($variant, $quantity)) { + throw new InsufficientInventoryException($variant->getKey(), $quantity, 0); + } + + $line->quantity = $quantity; + $line->unit_price_amount = (int) $variant->price_amount; + $this->recalculateLine($line); + $line->save(); + + $this->touchVersion($cart); + + return $line->refresh(); + }); + } + + public function removeLine(Cart $cart, int $lineId, ?int $expectedVersion = null): void + { + DB::transaction(function () use ($cart, $lineId, $expectedVersion): void { + $cart = $this->lockCart($cart); + $this->assertVersion($cart, $expectedVersion); + + CartLine::query() + ->where('cart_id', $cart->getKey()) + ->where('id', $lineId) + ->delete(); + + $this->touchVersion($cart); + }); + } + + public function mergeOnLogin(Cart $guest, Cart $customer): Cart + { + if ((int) $guest->store_id !== (int) $customer->store_id) { + throw new RuntimeException('Cannot merge carts across stores.'); + } + + return DB::transaction(function () use ($guest, $customer): Cart { + foreach ($guest->lines()->get() as $guestLine) { + $target = CartLine::query() + ->where('cart_id', $customer->getKey()) + ->where('variant_id', $guestLine->variant_id) + ->first(); + + if ($target === null) { + $guestLine->cart_id = $customer->getKey(); + $guestLine->save(); + } else { + $target->quantity = max($target->quantity, $guestLine->quantity); + $this->recalculateLine($target); + $target->save(); + $guestLine->delete(); + } + } + + $guest->status = CartStatus::Abandoned; + $guest->save(); + + $this->touchVersion($customer->refresh()); + + return $customer->refresh(); + }); + } + + public function recalculate(Cart $cart): void + { + foreach ($cart->lines as $line) { + $this->recalculateLine($line); + $line->save(); + } + } + + protected function recalculateLine(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; + } + + protected function lockCart(Cart $cart): Cart + { + $locked = Cart::query() + ->where('id', $cart->getKey()) + ->lockForUpdate() + ->first(); + + return $locked ?? $cart; + } + + protected function assertVersion(Cart $cart, ?int $expected): void + { + if ($expected === null) { + return; + } + + if ((int) $cart->cart_version !== $expected) { + throw new CartVersionConflictException($expected, (int) $cart->cart_version); + } + } + + protected function touchVersion(Cart $cart): Cart + { + $cart->cart_version = (int) $cart->cart_version + 1; + $cart->save(); + + return $cart; + } +} diff --git a/app/Services/CheckoutService.php b/app/Services/CheckoutService.php new file mode 100644 index 00000000..d4161cd8 --- /dev/null +++ b/app/Services/CheckoutService.php @@ -0,0 +1,208 @@ +where('cart_id', $cart->getKey()) + ->whereIn('status', [ + CheckoutStatus::Started->value, + CheckoutStatus::Addressed->value, + CheckoutStatus::ShippingSelected->value, + CheckoutStatus::PaymentSelected->value, + ]) + ->first(); + + if ($existing !== null) { + return $existing; + } + + $checkout = Checkout::query()->create([ + 'store_id' => $store->getKey(), + 'cart_id' => $cart->getKey(), + 'customer_id' => $cart->customer_id, + 'status' => CheckoutStatus::Started->value, + ]); + + $this->snapshotTotals($checkout); + + return $checkout; + } + + /** + * @param array $data + */ + public function setAddress(Checkout $checkout, array $data): Checkout + { + $this->assertActive($checkout); + + $email = (string) ($data['email'] ?? ''); + $shipping = (array) ($data['shipping_address'] ?? []); + $billing = (array) ($data['billing_address'] ?? $shipping); + + if ($email === '') { + throw new InvalidCheckoutStateException('Email is required.'); + } + + foreach (['first_name', 'last_name', 'address1', 'city', 'country_code', 'postal_code'] as $field) { + if (! isset($shipping[$field]) || trim((string) $shipping[$field]) === '') { + throw new InvalidCheckoutStateException("Address field '{$field}' is required."); + } + } + + $checkout->email = $email; + $checkout->shipping_address_json = $shipping; + $checkout->billing_address_json = $billing; + $checkout->status = CheckoutStatus::Addressed; + $checkout->save(); + + $this->snapshotTotals($checkout); + + return $checkout->refresh(); + } + + public function setShippingMethod(Checkout $checkout, int $rateId): Checkout + { + $this->assertActive($checkout); + + if ($checkout->status === CheckoutStatus::Started) { + throw new InvalidCheckoutStateException('Address must be set before shipping.'); + } + + $address = $checkout->shipping_address_json ?? []; + $rate = ShippingRate::query()->findOrFail($rateId); + + $zone = $rate->zone; + $matchingZone = $this->shipping->getMatchingZone($checkout->store, $address); + + if ($matchingZone === null || (int) $matchingZone->getKey() !== (int) $zone->getKey()) { + throw new InvalidCheckoutStateException('Shipping rate does not apply to this address.'); + } + + $checkout->shipping_method_id = $rate->getKey(); + $checkout->status = CheckoutStatus::ShippingSelected; + $checkout->save(); + + $this->snapshotTotals($checkout); + + return $checkout->refresh(); + } + + public function applyDiscount(Checkout $checkout, string $code): Checkout + { + $discount = $this->discounts->validate($code, $checkout->store, $checkout->cart); + + $checkout->discount_code = $discount->code; + $checkout->save(); + + $this->snapshotTotals($checkout); + + return $checkout->refresh(); + } + + public function removeDiscount(Checkout $checkout): Checkout + { + $checkout->discount_code = null; + $checkout->save(); + + $this->snapshotTotals($checkout); + + return $checkout->refresh(); + } + + public function selectPaymentMethod(Checkout $checkout, PaymentMethod $method): Checkout + { + $this->assertActive($checkout); + + if (! in_array($checkout->status, [CheckoutStatus::Addressed, CheckoutStatus::ShippingSelected, CheckoutStatus::PaymentSelected], true)) { + throw new InvalidCheckoutStateException('Cannot select payment for this checkout state.'); + } + + return DB::transaction(function () use ($checkout, $method): Checkout { + if ($checkout->status !== CheckoutStatus::PaymentSelected) { + foreach ($checkout->cart->lines()->with('variant')->get() as $line) { + if ($line->variant && $line->variant->requires_shipping) { + $this->inventory->reserve($line->variant, (int) $line->quantity); + } + } + } + + $checkout->payment_method = $method; + $checkout->status = CheckoutStatus::PaymentSelected; + $checkout->expires_at = now()->addHours(24); + $checkout->save(); + + $this->snapshotTotals($checkout); + + return $checkout->refresh(); + }); + } + + public function markCompleted(Checkout $checkout): Checkout + { + $checkout->status = CheckoutStatus::Completed; + $checkout->save(); + + return $checkout->refresh(); + } + + public function expireCheckout(Checkout $checkout): Checkout + { + if (! $checkout->status->isActive()) { + return $checkout; + } + + return DB::transaction(function () use ($checkout): Checkout { + if ($checkout->status === CheckoutStatus::PaymentSelected) { + foreach ($checkout->cart->lines()->with('variant')->get() as $line) { + if ($line->variant && $line->variant->requires_shipping) { + $this->inventory->release($line->variant, (int) $line->quantity); + } + } + } + + $checkout->status = CheckoutStatus::Expired; + $checkout->save(); + + return $checkout; + }); + } + + protected function snapshotTotals(Checkout $checkout): void + { + try { + $result = $this->pricing->calculate($checkout); + $checkout->totals_json = $result->toArray(); + $checkout->save(); + } catch (InvalidDiscountException) { + // pricing engine swallows discount exceptions internally; keep any prior snapshot + } + } + + protected function assertActive(Checkout $checkout): void + { + if (! $checkout->status->isActive()) { + throw new InvalidCheckoutStateException('Checkout is no longer active.'); + } + } +} diff --git a/app/Services/DashboardMetricsService.php b/app/Services/DashboardMetricsService.php new file mode 100644 index 00000000..96eacb80 --- /dev/null +++ b/app/Services/DashboardMetricsService.php @@ -0,0 +1,134 @@ +startOfDay(); + $dateStr = $date->toDateString(); + + $row = AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', $dateStr) + ->first(); + + if ($row !== null) { + return [ + 'date' => $dateStr, + 'orders_count' => $row->orders_count, + 'revenue_amount' => $row->revenue_amount, + 'aov_amount' => $row->aov_amount, + 'visits_count' => $row->visits_count, + 'add_to_cart_count' => $row->add_to_cart_count, + 'checkout_started_count' => $row->checkout_started_count, + 'checkout_completed_count' => $row->checkout_completed_count, + ]; + } + + return $this->computeForDay($store, $date); + } + + /** + * @return array{date: string, orders_count: int, revenue_amount: int, aov_amount: int, visits_count: int, add_to_cart_count: int, checkout_started_count: int, checkout_completed_count: int} + */ + public function computeForDay(Store $store, CarbonImmutable $date): array + { + $start = $date->startOfDay(); + $end = $date->endOfDay(); + + $counts = DB::table('analytics_events') + ->selectRaw('type, COUNT(*) AS c, COUNT(DISTINCT session_id) AS sessions') + ->where('store_id', $store->getKey()) + ->whereBetween('occurred_at', [$start, $end]) + ->groupBy('type') + ->get() + ->keyBy('type'); + + $visits = (int) ($counts->get(AnalyticsEventType::PageView->value)->sessions ?? 0); + $addToCart = (int) ($counts->get(AnalyticsEventType::AddToCart->value)->c ?? 0); + $checkoutStarted = (int) ($counts->get(AnalyticsEventType::CheckoutStarted->value)->c ?? 0); + $checkoutCompleted = (int) ($counts->get(AnalyticsEventType::CheckoutCompleted->value)->c ?? 0); + + $ordersCount = 0; + $revenue = 0; + + if (Schema::hasTable('orders')) { + $orderAgg = DB::table('orders') + ->selectRaw('COUNT(*) AS cnt, COALESCE(SUM(total_amount), 0) AS rev') + ->where('store_id', $store->getKey()) + ->whereBetween('created_at', [$start, $end]) + ->first(); + + $ordersCount = (int) ($orderAgg->cnt ?? 0); + $revenue = (int) ($orderAgg->rev ?? 0); + } + + $aov = $ordersCount > 0 ? (int) floor($revenue / $ordersCount) : 0; + + return [ + 'date' => $date->toDateString(), + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenue, + 'aov_amount' => $aov, + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $checkoutCompleted, + ]; + } + + /** + * Upsert the analytics_daily row for the given store/date. + */ + public function rollupDay(Store $store, CarbonImmutable $date): AnalyticsDaily + { + $metrics = $this->computeForDay($store, $date); + + DB::table('analytics_daily')->upsert([ + [ + 'store_id' => $store->getKey(), + 'date' => $metrics['date'], + 'orders_count' => $metrics['orders_count'], + 'revenue_amount' => $metrics['revenue_amount'], + 'aov_amount' => $metrics['aov_amount'], + 'visits_count' => $metrics['visits_count'], + 'add_to_cart_count' => $metrics['add_to_cart_count'], + 'checkout_started_count' => $metrics['checkout_started_count'], + 'checkout_completed_count' => $metrics['checkout_completed_count'], + ], + ], ['store_id', 'date'], [ + 'orders_count', + 'revenue_amount', + 'aov_amount', + 'visits_count', + 'add_to_cart_count', + 'checkout_started_count', + 'checkout_completed_count', + ]); + + /** @var AnalyticsDaily $row */ + $row = AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', $metrics['date']) + ->first(); + + return $row; + } +} diff --git a/app/Services/DiscountService.php b/app/Services/DiscountService.php new file mode 100644 index 00000000..e5415476 --- /dev/null +++ b/app/Services/DiscountService.php @@ -0,0 +1,155 @@ +withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereRaw('LOWER(code) = ?', [strtolower($code)]) + ->first(); + + if ($discount === null) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_NOT_FOUND); + } + + if ($discount->status !== DiscountStatus::Active) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_EXPIRED); + } + + $now = now(); + + if ($discount->starts_at !== null && $discount->starts_at->greaterThan($now)) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_NOT_YET_ACTIVE); + } + + if ($discount->ends_at !== null && $discount->ends_at->lessThan($now)) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_EXPIRED); + } + + if (! $discount->hasUsageRemaining()) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_USAGE_LIMIT_REACHED); + } + + $lines = $cart->lines()->get(); + $subtotal = (int) $lines->sum('line_subtotal_amount'); + + $rules = $discount->rules_json ?? []; + $minPurchase = $rules['min_purchase_amount'] ?? null; + + if ($minPurchase !== null && $subtotal < (int) $minPurchase) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_MIN_PURCHASE_NOT_MET); + } + + $qualifyingLines = $this->qualifyingLines($lines, $rules); + + if ($qualifyingLines->isEmpty() && $discount->value_type !== DiscountValueType::FreeShipping) { + throw new InvalidDiscountException(InvalidDiscountException::CODE_NOT_APPLICABLE); + } + + return $discount; + } + + public function calculate(Discount $discount, Cart $cart): DiscountResult + { + $lines = $cart->lines()->get(); + $rules = $discount->rules_json ?? []; + $qualifying = $this->qualifyingLines($lines, $rules); + + if ($discount->value_type === DiscountValueType::FreeShipping) { + return new DiscountResult($discount, 0, true, []); + } + + $qualifyingSubtotal = (int) $qualifying->sum('line_subtotal_amount'); + + if ($qualifyingSubtotal === 0) { + return new DiscountResult($discount, 0, false, []); + } + + $totalDiscount = $discount->value_type === DiscountValueType::Percent + ? (int) floor($qualifyingSubtotal * (int) $discount->value_amount / 100) + : min((int) $discount->value_amount, $qualifyingSubtotal); + + $allocations = []; + $remaining = $totalDiscount; + $qualifyingArr = $qualifying->values(); + $lastIndex = $qualifyingArr->count() - 1; + + foreach ($qualifyingArr as $i => $line) { + if ($i === $lastIndex) { + $amount = $remaining; + } else { + $amount = (int) floor($totalDiscount * $line->line_subtotal_amount / $qualifyingSubtotal); + $remaining -= $amount; + } + + $allocations[] = [ + 'line_id' => (int) $line->getKey(), + 'amount' => $amount, + ]; + } + + return new DiscountResult($discount, $totalDiscount, false, $allocations); + } + + public function incrementUsage(Discount $discount): void + { + $discount->increment('usage_count'); + } + + /** + * @param Collection $lines + * @param array $rules + * @return Collection + */ + protected function qualifyingLines(Collection $lines, array $rules): Collection + { + $productIds = $rules['applicable_product_ids'] ?? null; + $collectionIds = $rules['applicable_collection_ids'] ?? null; + + if (empty($productIds) && empty($collectionIds)) { + return $lines; + } + + return $lines->filter(function (CartLine $line) use ($productIds, $collectionIds): bool { + $variant = $line->variant()->with('product.collections')->first(); + + if ($variant === null || $variant->product === null) { + return false; + } + + if (! empty($productIds) && in_array((int) $variant->product->getKey(), array_map('intval', $productIds), true)) { + return true; + } + + if (! empty($collectionIds)) { + $lineCollectionIds = $variant->product->collections->pluck('id')->map(fn ($id): int => (int) $id)->all(); + + if (count(array_intersect(array_map('intval', $collectionIds), $lineCollectionIds)) > 0) { + return true; + } + } + + return false; + })->values(); + } +} diff --git a/app/Services/FulfillmentService.php b/app/Services/FulfillmentService.php new file mode 100644 index 00000000..44f72d18 --- /dev/null +++ b/app/Services/FulfillmentService.php @@ -0,0 +1,142 @@ + $lines + * @param array{tracking_company?: string|null, tracking_number?: string|null, tracking_url?: string|null} $tracking + */ + public function create(Order $order, array $lines, array $tracking = []): Fulfillment + { + if (! $order->financial_status->allowsFulfillment()) { + throw new FulfillmentGuardException( + 'Fulfillment cannot be created until payment is confirmed.', + ); + } + + if (empty($lines)) { + throw new RuntimeException('At least one fulfillment line is required.'); + } + + return DB::transaction(function () use ($order, $lines, $tracking): Fulfillment { + foreach ($lines as $entry) { + $orderLine = OrderLine::query()->where('order_id', $order->getKey()) + ->where('id', $entry['order_line_id']) + ->firstOrFail(); + + $unfulfilled = $orderLine->unfulfilledQuantity(); + + if ((int) $entry['quantity'] > $unfulfilled) { + throw new RuntimeException("Requested quantity exceeds unfulfilled on line {$orderLine->getKey()}."); + } + } + + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => FulfillmentShipmentStatus::Pending->value, + 'tracking_company' => $tracking['tracking_company'] ?? null, + 'tracking_number' => $tracking['tracking_number'] ?? null, + 'tracking_url' => $tracking['tracking_url'] ?? null, + 'created_at' => now(), + ]); + + foreach ($lines as $entry) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => (int) $entry['order_line_id'], + 'quantity' => (int) $entry['quantity'], + ]); + } + + $this->syncOrderFulfillmentStatus($order); + + FulfillmentCreated::dispatch($fulfillment); + + return $fulfillment->refresh(); + }); + } + + /** + * @param array $tracking + */ + public function markAsShipped(Fulfillment $fulfillment, array $tracking = []): Fulfillment + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Pending) { + throw new RuntimeException('Only pending fulfillments can be marked shipped.'); + } + + $fulfillment->fill([ + 'tracking_company' => $tracking['tracking_company'] ?? $fulfillment->tracking_company, + 'tracking_number' => $tracking['tracking_number'] ?? $fulfillment->tracking_number, + 'tracking_url' => $tracking['tracking_url'] ?? $fulfillment->tracking_url, + ]); + $fulfillment->status = FulfillmentShipmentStatus::Shipped; + $fulfillment->shipped_at = now(); + $fulfillment->save(); + + FulfillmentShipped::dispatch($fulfillment); + + return $fulfillment->refresh(); + } + + public function markAsDelivered(Fulfillment $fulfillment): Fulfillment + { + if ($fulfillment->status !== FulfillmentShipmentStatus::Shipped) { + throw new RuntimeException('Only shipped fulfillments can be marked delivered.'); + } + + $fulfillment->status = FulfillmentShipmentStatus::Delivered; + $fulfillment->save(); + + FulfillmentDelivered::dispatch($fulfillment); + + return $fulfillment->refresh(); + } + + protected function syncOrderFulfillmentStatus(Order $order): void + { + $lines = $order->lines()->get(); + $allFulfilled = true; + $anyFulfilled = false; + + foreach ($lines as $line) { + $fulfilled = $line->fulfilledQuantity(); + + if ($fulfilled > 0) { + $anyFulfilled = true; + } + + if ($fulfilled < $line->quantity) { + $allFulfilled = false; + } + } + + if ($allFulfilled) { + $order->fulfillment_status = FulfillmentStatus::Fulfilled; + $order->status = OrderStatus::Fulfilled; + $order->save(); + + OrderFulfilled::dispatch($order); + } elseif ($anyFulfilled) { + $order->fulfillment_status = FulfillmentStatus::Partial; + $order->save(); + } + } +} diff --git a/app/Services/InventoryService.php b/app/Services/InventoryService.php new file mode 100644 index 00000000..2a57be74 --- /dev/null +++ b/app/Services/InventoryService.php @@ -0,0 +1,107 @@ +loadItem($variant); + + if ($item === null) { + return false; + } + + if ($item->policy === InventoryPolicy::Continue) { + return true; + } + + return $item->available() >= $quantity; + } + + public function reserve(ProductVariant $variant, int $quantity): InventoryItem + { + return DB::transaction(function () use ($variant, $quantity): InventoryItem { + $item = $this->lockItem($variant); + + if ($item->policy === InventoryPolicy::Deny && $item->available() < $quantity) { + throw new InsufficientInventoryException( + $variant->getKey(), + $quantity, + $item->available(), + ); + } + + $item->quantity_reserved += $quantity; + $item->save(); + + return $item; + }); + } + + public function release(ProductVariant $variant, int $quantity): InventoryItem + { + return DB::transaction(function () use ($variant, $quantity): InventoryItem { + $item = $this->lockItem($variant); + + $item->quantity_reserved = max(0, $item->quantity_reserved - $quantity); + $item->save(); + + return $item; + }); + } + + public function commit(ProductVariant $variant, int $quantity): InventoryItem + { + return DB::transaction(function () use ($variant, $quantity): InventoryItem { + $item = $this->lockItem($variant); + + $item->quantity_on_hand -= $quantity; + $item->quantity_reserved = max(0, $item->quantity_reserved - $quantity); + $item->save(); + + return $item; + }); + } + + public function restock(ProductVariant $variant, int $quantity): InventoryItem + { + return DB::transaction(function () use ($variant, $quantity): InventoryItem { + $item = $this->lockItem($variant); + + $item->quantity_on_hand += $quantity; + $item->save(); + + return $item; + }); + } + + protected function loadItem(ProductVariant $variant): ?InventoryItem + { + return InventoryItem::query() + ->withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->first(); + } + + protected function lockItem(ProductVariant $variant): InventoryItem + { + $item = InventoryItem::query() + ->withoutGlobalScopes() + ->where('variant_id', $variant->getKey()) + ->lockForUpdate() + ->first(); + + if ($item === null) { + throw new \RuntimeException("Inventory item missing for variant {$variant->getKey()}."); + } + + return $item; + } +} diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php new file mode 100644 index 00000000..49ed2dd0 --- /dev/null +++ b/app/Services/OrderService.php @@ -0,0 +1,261 @@ +withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->where('email', $checkout->email) + ->where('created_at', '>=', now()->subMinutes(5)) + ->where('total_amount', (int) ($checkout->totals_json['total'] ?? 0)) + ->first(); + + if ($existing !== null && $checkout->status === CheckoutStatus::Completed) { + return $existing; + } + + $cart = $checkout->cart; + $lines = $cart->lines()->with('variant.product')->get(); + $totals = $checkout->totals_json ?? []; + $method = $checkout->payment_method ?? PaymentMethod::CreditCard; + + $inventoryAction = $method === PaymentMethod::BankTransfer ? 'keep_reserved' : 'commit'; + $financial = $method === PaymentMethod::BankTransfer ? FinancialStatus::Pending : FinancialStatus::Paid; + $status = $method === PaymentMethod::BankTransfer ? OrderStatus::Pending : OrderStatus::Paid; + + $customerResult = $this->attachOrCreateCustomer($checkout); + $checkout->customer_id = $customerResult['customer']->getKey(); + $checkout->save(); + + $order = Order::query()->create([ + 'store_id' => $checkout->store_id, + 'customer_id' => $checkout->customer_id, + 'order_number' => $this->generateOrderNumber(Store::query()->findOrFail($checkout->store_id)), + 'payment_method' => $method->value, + 'status' => $status->value, + 'financial_status' => $financial->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + 'currency' => $cart->currency, + 'subtotal_amount' => (int) ($totals['subtotal'] ?? $cart->subtotal()), + 'discount_amount' => (int) ($totals['discount'] ?? 0), + 'shipping_amount' => (int) ($totals['shipping'] ?? 0), + 'tax_amount' => (int) ($totals['tax_total'] ?? 0), + 'total_amount' => (int) ($totals['total'] ?? $cart->subtotal()), + 'email' => $checkout->email, + 'billing_address_json' => $checkout->billing_address_json, + 'shipping_address_json' => $checkout->shipping_address_json, + 'placed_at' => now(), + ]); + + foreach ($lines as $line) { + $variant = $line->variant; + $product = $variant?->product; + + $orderLine = OrderLine::query()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product?->getKey(), + 'variant_id' => $variant?->getKey(), + 'title_snapshot' => trim(($product->title ?? 'Item').' '.($variant?->sku ?? '')), + 'sku_snapshot' => $variant?->sku, + 'quantity' => (int) $line->quantity, + 'unit_price_amount' => (int) $line->unit_price_amount, + 'total_amount' => (int) $line->line_total_amount, + 'tax_lines_json' => [], + 'discount_allocations_json' => (int) $line->line_discount_amount > 0 + ? [['discount_id' => null, 'amount' => (int) $line->line_discount_amount]] + : [], + ]); + + if ($variant !== null && $inventoryAction === 'commit') { + $this->inventory->commit($variant, (int) $line->quantity); + } + // bank_transfer: inventory remains reserved (was reserved during selectPaymentMethod) + } + + $cart->status = CartStatus::Converted; + $cart->save(); + + $checkout->status = CheckoutStatus::Completed; + $checkout->save(); + + $this->maybeAutoFulfillDigital($order); + + OrderCreated::dispatch($order); + + if ($financial === FinancialStatus::Paid) { + OrderPaid::dispatch($order); + } + + if ($customerResult['created_guest']) { + $customerResult['customer']->notify(new CustomerWelcomeNotification($order->store_id)); + } + + return $order->refresh(); + }); + } + + /** + * @return array{customer: Customer, created_guest: bool} + */ + protected function attachOrCreateCustomer(Checkout $checkout): array + { + if ($checkout->customer_id !== null) { + $customer = Customer::query() + ->withoutGlobalScopes() + ->find($checkout->customer_id); + + if ($customer !== null) { + return ['customer' => $customer, 'created_guest' => false]; + } + } + + $email = (string) $checkout->email; + + $customer = Customer::query() + ->withoutGlobalScopes() + ->where('store_id', $checkout->store_id) + ->where('email', $email) + ->first(); + + if ($customer !== null) { + return ['customer' => $customer, 'created_guest' => false]; + } + + $shipping = (array) ($checkout->shipping_address_json ?? []); + $name = trim(((string) ($shipping['first_name'] ?? '')).' '.((string) ($shipping['last_name'] ?? ''))); + + $customer = new Customer; + $customer->store_id = $checkout->store_id; + $customer->email = $email; + $customer->password_hash = null; + $customer->name = $name !== '' ? $name : null; + $customer->marketing_opt_in = 0; + $customer->save(); + + return ['customer' => $customer, 'created_guest' => true]; + } + + public function generateOrderNumber(Store $store): string + { + $prefix = '#'; + $last = (int) Order::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->count(); + + return $prefix.(1000 + $last + 1); + } + + public function cancel(Order $order, ?string $reason = null): Order + { + if ($order->fulfillment_status === FulfillmentStatus::Fulfilled) { + throw new RuntimeException('Fulfilled orders cannot be cancelled.'); + } + + return DB::transaction(function () use ($order): Order { + foreach ($order->lines()->get() as $line) { + if ($line->variant_id === null) { + continue; + } + + $variant = $line->variant()->withoutGlobalScopes()->first(); + + if ($variant === null) { + continue; + } + + if ($order->financial_status === FinancialStatus::Pending) { + $this->inventory->release($variant, (int) $line->quantity); + } else { + $this->inventory->restock($variant, (int) $line->quantity); + } + } + + $order->status = OrderStatus::Cancelled; + $order->financial_status = FinancialStatus::Voided; + $order->save(); + + OrderCancelled::dispatch($order); + + return $order->refresh(); + }); + } + + public function close(Order $order): Order + { + if ($order->fulfillment_status !== FulfillmentStatus::Fulfilled) { + throw new RuntimeException('Only fulfilled orders can be closed.'); + } + + $order->status = OrderStatus::Fulfilled; + $order->save(); + + OrderFulfilled::dispatch($order); + + return $order->refresh(); + } + + protected function maybeAutoFulfillDigital(Order $order): void + { + $lines = $order->lines()->with('variant')->get(); + + if ($lines->isEmpty()) { + return; + } + + $allDigital = $lines->every(fn (OrderLine $line): bool => $line->variant === null + || ! $line->variant->requires_shipping); + + if (! $allDigital) { + return; + } + + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => FulfillmentShipmentStatus::Delivered->value, + 'shipped_at' => now(), + 'created_at' => now(), + ]); + + foreach ($lines as $line) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $line->getKey(), + 'quantity' => (int) $line->quantity, + ]); + } + + $order->fulfillment_status = FulfillmentStatus::Fulfilled; + $order->status = OrderStatus::Fulfilled; + $order->save(); + } +} diff --git a/app/Services/PaymentService.php b/app/Services/PaymentService.php new file mode 100644 index 00000000..36b9d8a4 --- /dev/null +++ b/app/Services/PaymentService.php @@ -0,0 +1,96 @@ + $details + */ + public function authorize(Checkout $checkout, PaymentMethod $method, array $details = []): PaymentResult + { + $result = $this->provider->authorize($checkout, $method, $details); + + if (! $result->success) { + $this->releaseReservations($checkout); + + throw new PaymentFailedException($result->errorCode ?? 'payment_failed'); + } + + return $result; + } + + public function recordPayment(Order $order, PaymentMethod $method, PaymentResult $result): Payment + { + return DB::transaction(function () use ($order, $method, $result): Payment { + $existing = Payment::query() + ->where('provider', 'mock') + ->where('provider_payment_id', $result->providerPaymentId) + ->first(); + + if ($existing !== null) { + return $existing; + } + + return Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $method->value, + 'provider_payment_id' => $result->providerPaymentId, + 'status' => $result->status->value, + 'amount' => $order->total_amount, + 'currency' => $order->currency, + 'raw_json_encrypted' => $result->raw, + 'created_at' => now(), + ]); + }); + } + + public function refund(Payment $payment, int $amount): PaymentResult + { + $result = $this->provider->refund($payment, $amount); + + if (! $result->success) { + throw new PaymentFailedException($result->errorCode ?? 'refund_failed'); + } + + if ($amount >= $payment->amount) { + $payment->status = PaymentStatus::Refunded; + $payment->save(); + } + + return new PaymentResult( + success: true, + status: $payment->status, + providerPaymentId: $payment->provider_payment_id, + raw: ['refund_id' => $result->providerRefundId], + ); + } + + public function releaseReservations(Checkout $checkout): void + { + foreach ($checkout->cart->lines()->with('variant')->get() as $line) { + if ($line->variant && $line->variant->requires_shipping) { + $this->inventory->release($line->variant, (int) $line->quantity); + } + } + } +} diff --git a/app/Services/Payments/MockPaymentProvider.php b/app/Services/Payments/MockPaymentProvider.php new file mode 100644 index 00000000..67660e5e --- /dev/null +++ b/app/Services/Payments/MockPaymentProvider.php @@ -0,0 +1,81 @@ + $details + */ + public function authorize(Checkout $checkout, PaymentMethod $method, array $details): PaymentResult + { + $providerId = 'mock_auth_'.Str::random(16); + + return match ($method) { + PaymentMethod::CreditCard => $this->authorizeCreditCard($providerId, $details), + PaymentMethod::Paypal => PaymentResult::captured($providerId, ['method' => 'paypal']), + PaymentMethod::BankTransfer => PaymentResult::pending($providerId, ['method' => 'bank_transfer']), + }; + } + + public function capture(Payment $payment): PaymentResult + { + return PaymentResult::captured( + (string) $payment->provider_payment_id, + ['capture_id' => 'mock_cap_'.Str::random(12)], + ); + } + + public function void(Payment $payment): PaymentResult + { + return new PaymentResult( + success: true, + status: \App\Enums\PaymentStatus::Failed, + providerPaymentId: $payment->provider_payment_id, + raw: ['voided' => true], + ); + } + + public function refund(Payment $payment, int $amount): RefundResult + { + if ($amount <= 0) { + return RefundResult::failed('invalid_amount'); + } + + return RefundResult::succeeded( + 'mock_refund_'.Str::random(16), + ['amount' => $amount], + ); + } + + /** + * @param array $details + */ + protected function authorizeCreditCard(string $providerId, array $details): PaymentResult + { + $card = preg_replace('/\D+/', '', (string) ($details['card_number'] ?? '')); + + return match ($card) { + self::CARD_DECLINE => PaymentResult::declined('card_declined', ['card' => $card]), + self::CARD_INSUFFICIENT_FUNDS => PaymentResult::declined('insufficient_funds', ['card' => $card]), + self::CARD_3DS, self::CARD_SUCCESS, '' => PaymentResult::captured($providerId, ['card' => $card]), + default => PaymentResult::captured($providerId, ['card' => $card]), + }; + } +} diff --git a/app/Services/PricingEngine.php b/app/Services/PricingEngine.php new file mode 100644 index 00000000..b6328ea6 --- /dev/null +++ b/app/Services/PricingEngine.php @@ -0,0 +1,113 @@ +cart; + $store = $checkout->store ?? Store::query()->findOrFail($checkout->store_id); + + $lines = $cart->lines()->get(); + $lineAmounts = []; + + foreach ($lines as $line) { + $lineAmounts[$line->getKey()] = [ + 'subtotal' => (int) $line->line_subtotal_amount, + 'discount' => 0, + ]; + } + + $subtotal = array_sum(array_column($lineAmounts, 'subtotal')); + + $discountResult = null; + + if ($checkout->discount_code !== null) { + try { + $discount = $this->discounts->validate($checkout->discount_code, $store, $cart); + $discountResult = $this->discounts->calculate($discount, $cart); + + foreach ($discountResult->allocations as $allocation) { + if (isset($lineAmounts[$allocation['line_id']])) { + $lineAmounts[$allocation['line_id']]['discount'] = (int) $allocation['amount']; + } + } + } catch (InvalidDiscountException) { + $discountResult = null; + } + } + + $discountTotal = array_sum(array_column($lineAmounts, 'discount')); + + $shippingAmount = $this->computeShipping($checkout, $cart, $discountResult); + + $taxAddress = $checkout->shipping_address_json ?? []; + $settings = TaxSettings::query()->firstOrNew(['store_id' => (int) $store->getKey()]); + + $taxLineEntries = []; + + foreach ($lines as $line) { + $lineInfo = $lineAmounts[$line->getKey()] ?? null; + + if ($lineInfo === null) { + continue; + } + + $taxLineEntries[] = [ + 'amount' => $lineInfo['subtotal'] - $lineInfo['discount'], + 'label' => 'Tax', + ]; + } + + $taxResult = $this->tax->calculate($taxLineEntries, $settings, $taxAddress, $shippingAmount); + + $total = ($subtotal - $discountTotal) + $shippingAmount + $taxResult['total']; + + return new PricingResult( + subtotal: $subtotal, + discount: $discountTotal, + shipping: $shippingAmount, + taxLines: $taxResult['lines'], + taxTotal: $taxResult['total'], + total: $total, + currency: $cart->currency, + ); + } + + protected function computeShipping(Checkout $checkout, Cart $cart, ?DiscountResult $discountResult): int + { + if ($checkout->shipping_method_id === null) { + return 0; + } + + $rate = ShippingRate::query()->find($checkout->shipping_method_id); + + if ($rate === null) { + return 0; + } + + $amount = $this->shipping->calculate($rate, $cart) ?? 0; + + if ($discountResult !== null && $discountResult->freeShipping) { + return 0; + } + + return $amount; + } +} diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php new file mode 100644 index 00000000..18356ae2 --- /dev/null +++ b/app/Services/ProductService.php @@ -0,0 +1,204 @@ + $attributes + */ + public function create(int $storeId, array $attributes): Product + { + return DB::transaction(function () use ($storeId, $attributes): Product { + $title = (string) ($attributes['title'] ?? ''); + + if (trim($title) === '') { + throw new \InvalidArgumentException('Product title is required.'); + } + + $handle = $attributes['handle'] ?? null; + $handle = HandleGenerator::unique( + Product::class, + $storeId, + $handle !== null && $handle !== '' ? $handle : $title, + ); + + $product = new Product([ + 'title' => $title, + 'handle' => $handle, + 'status' => ProductStatus::Draft->value, + 'description_html' => $attributes['description_html'] ?? null, + 'vendor' => $attributes['vendor'] ?? null, + 'product_type' => $attributes['product_type'] ?? null, + 'tags' => $attributes['tags'] ?? [], + ]); + $product->store_id = $storeId; + $product->save(); + + $this->ensureDefaultVariant($product, $attributes); + + return $product->refresh(); + }); + } + + /** + * @param array $attributes + */ + public function update(Product $product, array $attributes): Product + { + return DB::transaction(function () use ($product, $attributes): Product { + if (array_key_exists('title', $attributes) && trim((string) $attributes['title']) === '') { + throw new \InvalidArgumentException('Product title cannot be empty.'); + } + + if (array_key_exists('handle', $attributes) && $attributes['handle'] !== null && $attributes['handle'] !== '') { + $attributes['handle'] = HandleGenerator::unique( + Product::class, + (int) $product->store_id, + (string) $attributes['handle'], + $product->getKey(), + ); + } else { + unset($attributes['handle']); + } + + $product->fill($attributes); + $product->save(); + + return $product->refresh(); + }); + } + + public function transitionStatus(Product $product, ProductStatus $newStatus): Product + { + return DB::transaction(function () use ($product, $newStatus): Product { + $current = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + if ($current === $newStatus) { + return $product; + } + + $this->assertValidTransition($product, $current, $newStatus); + + $product->status = $newStatus; + + if ($newStatus === ProductStatus::Active && $product->published_at === null) { + $product->published_at = now(); + } + + $product->save(); + + return $product->refresh(); + }); + } + + public function delete(Product $product): void + { + DB::transaction(function () use ($product): void { + $current = $product->status instanceof ProductStatus + ? $product->status + : ProductStatus::from((string) $product->status); + + if ($current !== ProductStatus::Draft) { + throw new RuntimeException('Only draft products may be deleted. Archive instead.'); + } + + if ($this->hasOrderReferences($product)) { + throw new RuntimeException('Product with order history may not be deleted.'); + } + + $product->delete(); + }); + } + + /** + * @param array $attributes + */ + protected function ensureDefaultVariant(Product $product, array $attributes): void + { + $variant = new ProductVariant([ + 'sku' => $attributes['sku'] ?? null, + 'price_amount' => (int) ($attributes['price_amount'] ?? 0), + 'compare_at_amount' => $attributes['compare_at_amount'] ?? null, + 'currency' => $attributes['currency'] ?? 'USD', + 'weight_g' => $attributes['weight_g'] ?? null, + 'requires_shipping' => (int) ($attributes['requires_shipping'] ?? 1), + 'is_default' => 1, + 'position' => 0, + 'status' => VariantStatus::Active->value, + ]); + $variant->product_id = $product->getKey(); + $variant->save(); + + $inventory = new InventoryItem([ + 'quantity_on_hand' => (int) ($attributes['quantity_on_hand'] ?? 0), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]); + $inventory->store_id = $product->store_id; + $inventory->variant_id = $variant->getKey(); + $inventory->save(); + } + + protected function assertValidTransition(Product $product, ProductStatus $from, ProductStatus $to): void + { + $valid = match ($from) { + ProductStatus::Draft => in_array($to, [ProductStatus::Active, ProductStatus::Archived], true), + ProductStatus::Active => in_array($to, [ProductStatus::Archived, ProductStatus::Draft], true), + ProductStatus::Archived => in_array($to, [ProductStatus::Active, ProductStatus::Draft], true), + }; + + if (! $valid) { + throw new InvalidProductTransitionException($from, $to); + } + + if ($to === ProductStatus::Active) { + if (trim((string) $product->title) === '') { + throw new InvalidProductTransitionException($from, $to, 'Cannot activate product without a title.'); + } + + $hasPricedVariant = $product->variants() + ->where('status', VariantStatus::Active->value) + ->where('price_amount', '>', 0) + ->exists(); + + if (! $hasPricedVariant) { + throw new InvalidProductTransitionException($from, $to, 'Cannot activate product without at least one priced active variant.'); + } + } + + if ($to === ProductStatus::Draft && $this->hasOrderReferences($product)) { + throw new InvalidProductTransitionException($from, $to, 'Cannot revert product to draft once sold.'); + } + } + + protected function hasOrderReferences(Product $product): bool + { + if (! \Illuminate\Support\Facades\Schema::hasTable('order_lines')) { + return false; + } + + $variantIds = $product->variants()->pluck('id'); + + if ($variantIds->isEmpty()) { + return false; + } + + return DB::table('order_lines') + ->whereIn('variant_id', $variantIds) + ->exists(); + } +} diff --git a/app/Services/RefundService.php b/app/Services/RefundService.php new file mode 100644 index 00000000..07dd59fe --- /dev/null +++ b/app/Services/RefundService.php @@ -0,0 +1,102 @@ + $restockLines + */ + public function create( + Order $order, + Payment $payment, + int $amount, + ?string $reason = null, + bool $restock = false, + array $restockLines = [], + ): Refund { + if ($amount <= 0) { + throw new RuntimeException('Refund amount must be positive.'); + } + + $remaining = $order->remainingRefundable(); + + if ($amount > $remaining) { + throw new RuntimeException("Refund amount {$amount} exceeds refundable {$remaining}."); + } + + return DB::transaction(function () use ($order, $payment, $amount, $reason, $restock, $restockLines): Refund { + $this->payments->refund($payment, $amount); + + $refund = Refund::query()->create([ + 'order_id' => $order->getKey(), + 'payment_id' => $payment->getKey(), + 'amount' => $amount, + 'reason' => $reason, + 'status' => RefundStatus::Processed->value, + 'provider_refund_id' => 'mock_refund_'.uniqid(), + 'created_at' => now(), + ]); + + if ($restock) { + $this->restockLines($order, $restockLines); + } + + $totalRefunded = $order->totalRefunded() + 0; + + if ($totalRefunded >= $order->total_amount) { + $order->financial_status = FinancialStatus::Refunded; + $order->status = OrderStatus::Refunded; + } else { + $order->financial_status = FinancialStatus::PartiallyRefunded; + } + + $order->save(); + + OrderRefunded::dispatch($order->refresh(), $refund); + + return $refund; + }); + } + + /** + * @param array $restockLines + */ + protected function restockLines(Order $order, array $restockLines): void + { + if (empty($restockLines)) { + foreach ($order->lines()->with('variant')->get() as $line) { + if ($line->variant !== null) { + $this->inventory->restock($line->variant, (int) $line->quantity); + } + } + + return; + } + + foreach ($restockLines as $entry) { + $line = $order->lines()->find($entry['order_line_id']); + + if ($line === null || $line->variant === null) { + continue; + } + + $this->inventory->restock($line->variant, (int) $entry['quantity']); + } + } +} diff --git a/app/Services/SearchService.php b/app/Services/SearchService.php new file mode 100644 index 00000000..fda1b446 --- /dev/null +++ b/app/Services/SearchService.php @@ -0,0 +1,127 @@ + $filters + * @return Collection + */ + public function search(Store $store, string $query, array $filters = [], ?string $sessionId = null, bool $log = true): Collection + { + $trimmed = trim($query); + + if ($trimmed === '') { + if ($log) { + $this->log($store, $trimmed, $filters, 0, $sessionId); + } + + /** @var Collection $empty */ + $empty = new Collection; + + return $empty; + } + + $match = $this->buildMatchExpression($trimmed); + + if ($match === '') { + if ($log) { + $this->log($store, $trimmed, $filters, 0, $sessionId); + } + + /** @var Collection $empty */ + $empty = new Collection; + + return $empty; + } + + $rows = DB::table('products_fts') + ->selectRaw('product_id, bm25(products_fts) AS rank') + ->whereRaw('products_fts MATCH ?', [$match]) + ->where('store_id', $store->getKey()) + ->orderBy('rank') + ->limit($this->limit) + ->get(); + + $ids = $rows->pluck('product_id')->map(fn ($id): int => (int) $id)->all(); + + if ($ids === []) { + if ($log) { + $this->log($store, $trimmed, $filters, 0, $sessionId); + } + + /** @var Collection $empty */ + $empty = new Collection; + + return $empty; + } + + $products = Product::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->whereIn('id', $ids) + ->where('status', ProductStatus::Active->value) + ->whereNotNull('published_at') + ->with(['variants' => fn ($q) => $q->orderBy('position')]) + ->get() + ->sortBy(fn (Product $p) => array_search($p->getKey(), $ids, true)) + ->values(); + + if ($log) { + $this->log($store, $trimmed, $filters, $products->count(), $sessionId); + } + + return Collection::make($products); + } + + protected function buildMatchExpression(string $query): string + { + $sanitized = preg_replace('/[^\p{L}\p{N}\s]/u', ' ', $query) ?? ''; + $tokens = preg_split('/\s+/', trim($sanitized)) ?: []; + $reserved = ['AND', 'OR', 'NOT', 'NEAR']; + $tokens = array_values(array_filter( + $tokens, + fn (string $t): bool => $t !== '' && ! in_array(strtoupper($t), $reserved, true), + )); + + if ($tokens === []) { + return ''; + } + + $last = array_pop($tokens); + $pieces = array_map(fn (string $t): string => $this->quoteToken($t), $tokens); + $pieces[] = $this->quoteToken($last).'*'; + + return implode(' ', $pieces); + } + + protected function quoteToken(string $token): string + { + return '"'.str_replace('"', '', $token).'"'; + } + + /** + * @param array $filters + */ + protected function log(Store $store, string $query, array $filters, int $resultsCount, ?string $sessionId): void + { + SearchQuery::query()->create([ + 'store_id' => $store->getKey(), + 'query' => $query, + 'filters_json' => $filters === [] ? null : $filters, + 'results_count' => $resultsCount, + 'session_id' => $sessionId, + 'created_at' => now(), + ]); + } +} diff --git a/app/Services/ShippingCalculator.php b/app/Services/ShippingCalculator.php new file mode 100644 index 00000000..e6141a82 --- /dev/null +++ b/app/Services/ShippingCalculator.php @@ -0,0 +1,130 @@ + $address + */ + public function getMatchingZone(Store $store, array $address): ?ShippingZone + { + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + $regionCode = strtoupper((string) ($address['province_code'] ?? '')); + + if ($countryCode === '') { + return null; + } + + $zones = ShippingZone::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get(); + + $bestMatch = null; + $bestSpecificity = -1; + + foreach ($zones as $zone) { + $countries = array_map(fn ($v): string => strtoupper((string) $v), $zone->countries_json ?? []); + $regions = array_map(fn ($v): string => strtoupper((string) $v), $zone->regions_json ?? []); + + if (! in_array($countryCode, $countries, true)) { + continue; + } + + $regionKey = $countryCode.'-'.$regionCode; + $specificity = ($regionCode !== '' && in_array($regionKey, $regions, true)) ? 2 : 1; + + if ($specificity > $bestSpecificity) { + $bestMatch = $zone; + $bestSpecificity = $specificity; + } elseif ($bestMatch !== null && $specificity === $bestSpecificity && $zone->getKey() < $bestMatch->getKey()) { + $bestMatch = $zone; + } + } + + return $bestMatch; + } + + /** + * @param array $address + * @return Collection + */ + public function getAvailableRates(Store $store, array $address): Collection + { + $zone = $this->getMatchingZone($store, $address); + + if ($zone === null) { + return collect(); + } + + return $zone->rates()->where('is_active', 1)->get(); + } + + public function calculate(ShippingRate $rate, Cart $cart): ?int + { + $config = $rate->config_json ?? []; + $lines = $cart->lines()->with('variant')->get(); + + $physicalLines = $lines->filter(fn ($line): bool => $line->variant && $line->variant->requires_shipping)->values(); + + if ($physicalLines->isEmpty()) { + return 0; + } + + return match ($rate->type) { + ShippingRateType::Flat => isset($config['amount']) ? (int) $config['amount'] : null, + ShippingRateType::Weight => $this->calculateByWeight($config, $physicalLines), + ShippingRateType::Price => $this->calculateByPrice($config, (int) $lines->sum('line_subtotal_amount')), + ShippingRateType::Carrier => isset($config['amount']) ? (int) $config['amount'] : null, + }; + } + + /** + * @param array $config + * @param Collection $lines + */ + protected function calculateByWeight(array $config, Collection $lines): ?int + { + $totalWeight = 0; + + foreach ($lines as $line) { + $totalWeight += ((int) ($line->variant->weight_g ?? 0)) * $line->quantity; + } + + foreach ($config['ranges'] ?? [] as $range) { + $min = (int) ($range['min_g'] ?? 0); + $max = isset($range['max_g']) ? (int) $range['max_g'] : PHP_INT_MAX; + + if ($totalWeight >= $min && $totalWeight <= $max) { + return (int) ($range['amount'] ?? 0); + } + } + + return null; + } + + /** + * @param array $config + */ + protected function calculateByPrice(array $config, int $subtotal): ?int + { + foreach ($config['ranges'] ?? [] as $range) { + $min = (int) ($range['min_amount'] ?? 0); + $max = isset($range['max_amount']) ? (int) $range['max_amount'] : PHP_INT_MAX; + + if ($subtotal >= $min && $subtotal <= $max) { + return (int) ($range['amount'] ?? 0); + } + } + + return null; + } +} diff --git a/app/Services/TaxCalculator.php b/app/Services/TaxCalculator.php new file mode 100644 index 00000000..0f9a2627 --- /dev/null +++ b/app/Services/TaxCalculator.php @@ -0,0 +1,84 @@ + $lineAmounts discounted line amounts + * @param array $address + * @return array{lines: array, total: int} + */ + public function calculate(array $lineAmounts, TaxSettings $settings, array $address, int $shippingAmount = 0): array + { + if ($settings->mode === TaxMode::Provider || $this->resolveRate($settings, $address) === 0) { + return ['lines' => [], 'total' => 0]; + } + + $rate = $this->resolveRate($settings, $address); + + $lines = []; + $total = 0; + + foreach ($lineAmounts as $entry) { + $amount = (int) $entry['amount']; + $taxAmount = $settings->prices_include_tax + ? $this->extractInclusive($amount, $rate) + : $this->addExclusive($amount, $rate); + + $lines[] = new TaxLine($entry['label'] ?? 'Tax', $rate, $taxAmount); + $total += $taxAmount; + } + + if ($shippingAmount > 0 && ($settings->config_json['shipping_taxable'] ?? false)) { + $shippingTax = $settings->prices_include_tax + ? $this->extractInclusive($shippingAmount, $rate) + : $this->addExclusive($shippingAmount, $rate); + $lines[] = new TaxLine('Shipping tax', $rate, $shippingTax); + $total += $shippingTax; + } + + return ['lines' => $lines, 'total' => $total]; + } + + public function extractInclusive(int $grossAmount, int $rateBasisPoints): int + { + if ($rateBasisPoints === 0) { + return 0; + } + + $net = intdiv($grossAmount * 10000, 10000 + $rateBasisPoints); + + return $grossAmount - $net; + } + + public function addExclusive(int $netAmount, int $rateBasisPoints): int + { + if ($rateBasisPoints === 0) { + return 0; + } + + return (int) round($netAmount * $rateBasisPoints / 10000); + } + + /** + * @param array $address + */ + protected function resolveRate(TaxSettings $settings, array $address): int + { + $config = $settings->config_json ?? []; + $countryCode = strtoupper((string) ($address['country_code'] ?? $address['country'] ?? '')); + + $countryRates = $config['country_rates'] ?? []; + + if ($countryCode !== '' && isset($countryRates[$countryCode])) { + return (int) $countryRates[$countryCode]; + } + + return (int) ($config['default_rate_bps'] ?? 0); + } +} diff --git a/app/Services/VariantMatrixService.php b/app/Services/VariantMatrixService.php new file mode 100644 index 00000000..e76f8617 --- /dev/null +++ b/app/Services/VariantMatrixService.php @@ -0,0 +1,135 @@ +options()->with('values')->get(); + + $valueGroups = $options->map(function ($option) { + return $option->values->map(fn ($value): int => (int) $value->getKey())->all(); + })->all(); + + $desiredCombos = $this->cartesianProduct($valueGroups); + + $existingVariants = $product->variants()->with('optionValues')->get(); + $existingSets = []; + + foreach ($existingVariants as $variant) { + $ids = $variant->optionValues->pluck('id')->map(fn ($id): int => (int) $id)->sort()->values()->all(); + $key = implode('|', $ids); + $existingSets[$key] = $variant; + } + + $defaultPrice = (int) ($existingVariants->first()->price_amount ?? 0); + $defaultCurrency = $existingVariants->first()->currency ?? 'USD'; + + $matchedKeys = []; + $position = 0; + + foreach ($desiredCombos as $combo) { + sort($combo); + $key = implode('|', $combo); + + if (isset($existingSets[$key])) { + $matchedKeys[$key] = true; + $existingSets[$key]->update(['position' => $position]); + $position++; + + continue; + } + + $variant = new ProductVariant([ + 'price_amount' => $defaultPrice, + 'currency' => $defaultCurrency, + 'requires_shipping' => 1, + 'is_default' => 0, + 'position' => $position, + 'status' => VariantStatus::Active->value, + ]); + $variant->product_id = $product->getKey(); + $variant->save(); + + if (! empty($combo)) { + $variant->optionValues()->sync($combo); + } + + $inventory = new InventoryItem([ + 'quantity_on_hand' => 0, + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]); + $inventory->store_id = $product->store_id; + $inventory->variant_id = $variant->getKey(); + $inventory->save(); + + $position++; + } + + foreach ($existingSets as $key => $variant) { + if (isset($matchedKeys[$key])) { + continue; + } + + if ($this->hasOrderReferences((int) $variant->getKey())) { + $variant->update(['status' => VariantStatus::Archived->value]); + } else { + $variant->delete(); + } + } + }); + } + + /** + * @param array> $groups + * @return array> + */ + protected function cartesianProduct(array $groups): array + { + if ($groups === []) { + return []; + } + + $groups = array_values(array_filter($groups, fn (array $g): bool => $g !== [])); + + if ($groups === []) { + return []; + } + + $result = [[]]; + + foreach ($groups as $group) { + $next = []; + + foreach ($result as $partial) { + foreach ($group as $value) { + $next[] = array_merge($partial, [$value]); + } + } + + $result = $next; + } + + return $result; + } + + protected function hasOrderReferences(int $variantId): bool + { + if (! Schema::hasTable('order_lines')) { + return false; + } + + return DB::table('order_lines')->where('variant_id', $variantId)->exists(); + } +} diff --git a/app/Services/WebhookDispatcher.php b/app/Services/WebhookDispatcher.php new file mode 100644 index 00000000..0a6d3598 --- /dev/null +++ b/app/Services/WebhookDispatcher.php @@ -0,0 +1,64 @@ + $payload + */ + public function dispatch(WebhookTopic $topic, int $storeId, array $payload): int + { + $subscriptions = WebhookSubscription::query() + ->withoutGlobalScopes() + ->where('store_id', $storeId) + ->where('event_type', $topic->value) + ->where('status', 'active') + ->get(); + + $eventId = (string) Str::uuid(); + $timestamp = now()->timestamp; + + foreach ($subscriptions as $subscription) { + DeliverWebhook::dispatch( + $subscription->getKey(), + $eventId, + $topic->value, + $payload, + $timestamp, + ); + } + + return $subscriptions->count(); + } + + /** + * Convenience dispatcher that resolves store from the payload or current_store binding. + * + * @param array $payload + */ + public function dispatchForTopic(WebhookTopic $topic, ?int $storeId, array $payload): int + { + if ($storeId === null) { + if (! app()->bound('current_store')) { + return 0; + } + + $store = app('current_store'); + + if (! $store instanceof Store) { + return 0; + } + + $storeId = (int) $store->getKey(); + } + + return $this->dispatch($topic, $storeId, $payload); + } +} diff --git a/app/Support/HandleGenerator.php b/app/Support/HandleGenerator.php new file mode 100644 index 00000000..5e0ec7af --- /dev/null +++ b/app/Support/HandleGenerator.php @@ -0,0 +1,60 @@ + $modelClass + */ + public static function unique( + string $modelClass, + int $storeId, + string $source, + ?int $ignoreId = null, + string $column = 'handle' + ): string { + $base = Str::slug($source); + + if ($base === '') { + $base = 'item'; + } + + $candidate = $base; + $suffix = 1; + + while (self::exists($modelClass, $storeId, $column, $candidate, $ignoreId)) { + $suffix++; + $candidate = $base.'-'.$suffix; + } + + return $candidate; + } + + /** + * @param class-string $modelClass + */ + protected static function exists( + string $modelClass, + int $storeId, + string $column, + string $value, + ?int $ignoreId + ): bool { + $query = $modelClass::query() + ->withoutGlobalScopes() + ->where('store_id', $storeId) + ->where($column, $value); + + if ($ignoreId !== null) { + $query->where('id', '!=', $ignoreId); + } + + return $query->exists(); + } +} diff --git a/app/Traits/ChecksStoreRole.php b/app/Traits/ChecksStoreRole.php new file mode 100644 index 00000000..fbce7c6a --- /dev/null +++ b/app/Traits/ChecksStoreRole.php @@ -0,0 +1,65 @@ +stores() + ->wherePivot('store_id', $storeId) + ->first()?->pivot; + + if ($pivot === null) { + return null; + } + + $role = $pivot->role; + + return $role instanceof StoreUserRole ? $role : StoreUserRole::tryFrom((string) $role); + } + + /** + * @param array $roles + */ + public function hasRole(User $user, int $storeId, array $roles): bool + { + $role = $this->getStoreRole($user, $storeId); + + return $role !== null && in_array($role, $roles, true); + } + + public function isOwnerOrAdmin(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [StoreUserRole::Owner, StoreUserRole::Admin]); + } + + public function isOwnerAdminOrStaff(User $user, int $storeId): bool + { + return $this->hasRole($user, $storeId, [ + StoreUserRole::Owner, + StoreUserRole::Admin, + StoreUserRole::Staff, + ]); + } + + public function isAnyRole(User $user, int $storeId): bool + { + return $this->getStoreRole($user, $storeId) !== null; + } + + protected function resolveCurrentStoreId(): ?int + { + if (! app()->bound('current_store')) { + return null; + } + + $store = app('current_store'); + + return $store instanceof Store ? (int) $store->getKey() : null; + } +} diff --git a/app/ValueObjects/DiscountResult.php b/app/ValueObjects/DiscountResult.php new file mode 100644 index 00000000..6bdec2d4 --- /dev/null +++ b/app/ValueObjects/DiscountResult.php @@ -0,0 +1,18 @@ + $allocations + */ + public function __construct( + public readonly Discount $discount, + public readonly int $totalAmount, + public readonly bool $freeShipping, + public readonly array $allocations, + ) {} +} diff --git a/app/ValueObjects/PaymentResult.php b/app/ValueObjects/PaymentResult.php new file mode 100644 index 00000000..8a6758f5 --- /dev/null +++ b/app/ValueObjects/PaymentResult.php @@ -0,0 +1,34 @@ + $raw + */ + public function __construct( + public readonly bool $success, + public readonly PaymentStatus $status, + public readonly ?string $providerPaymentId = null, + public readonly ?string $errorCode = null, + public readonly array $raw = [], + ) {} + + public static function captured(string $providerId, array $raw = []): self + { + return new self(true, PaymentStatus::Captured, $providerId, null, $raw); + } + + public static function pending(string $providerId, array $raw = []): self + { + return new self(true, PaymentStatus::Pending, $providerId, null, $raw); + } + + public static function declined(string $errorCode, array $raw = []): self + { + return new self(false, PaymentStatus::Failed, null, $errorCode, $raw); + } +} diff --git a/app/ValueObjects/PricingResult.php b/app/ValueObjects/PricingResult.php new file mode 100644 index 00000000..1b73fe03 --- /dev/null +++ b/app/ValueObjects/PricingResult.php @@ -0,0 +1,35 @@ + $taxLines + */ + public function __construct( + public readonly int $subtotal, + public readonly int $discount, + public readonly int $shipping, + public readonly array $taxLines, + public readonly int $taxTotal, + public readonly int $total, + public readonly string $currency, + ) {} + + /** + * @return array{subtotal: int, discount: int, shipping: int, tax_lines: array, tax_total: int, total: int, currency: string} + */ + public function toArray(): array + { + return [ + 'subtotal' => $this->subtotal, + 'discount' => $this->discount, + 'shipping' => $this->shipping, + 'tax_lines' => array_map(fn (TaxLine $line): array => $line->toArray(), $this->taxLines), + 'tax_total' => $this->taxTotal, + 'total' => $this->total, + 'currency' => $this->currency, + ]; + } +} diff --git a/app/ValueObjects/RefundResult.php b/app/ValueObjects/RefundResult.php new file mode 100644 index 00000000..d432c854 --- /dev/null +++ b/app/ValueObjects/RefundResult.php @@ -0,0 +1,26 @@ + $raw + */ + public function __construct( + public readonly bool $success, + public readonly ?string $providerRefundId, + public readonly ?string $errorCode = null, + public readonly array $raw = [], + ) {} + + public static function succeeded(string $providerId, array $raw = []): self + { + return new self(true, $providerId, null, $raw); + } + + public static function failed(string $errorCode, array $raw = []): self + { + return new self(false, null, $errorCode, $raw); + } +} diff --git a/app/ValueObjects/TaxLine.php b/app/ValueObjects/TaxLine.php new file mode 100644 index 00000000..61999dab --- /dev/null +++ b/app/ValueObjects/TaxLine.php @@ -0,0 +1,24 @@ + $this->name, + 'rate' => $this->rate, + 'amount' => $this->amount, + ]; + } +} diff --git a/boost.json b/boost.json new file mode 100644 index 00000000..785d00da --- /dev/null +++ b/boost.json @@ -0,0 +1,18 @@ +{ + "agents": [ + "opencode", + "copilot" + ], + "guidelines": true, + "mcp": true, + "nightwatch_mcp": false, + "sail": false, + "skills": [ + "developing-with-fortify", + "laravel-best-practices", + "fluxui-development", + "livewire-development", + "pest-testing", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c1832766..90cd7acc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', commands: __DIR__.'/../routes/console.php', health: '/up', + apiPrefix: 'api', ) ->withMiddleware(function (Middleware $middleware): void { - // + $middleware->alias([ + 'store.resolve' => ResolveStore::class, + ]); }) ->withExceptions(function (Exceptions $exceptions): void { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 0ad9c573..20485e52 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,5 +2,6 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\EventServiceProvider::class, App\Providers\FortifyServiceProvider::class, ]; diff --git a/composer.json b/composer.json index 1f848aaf..72e094db 100644 --- a/composer.json +++ b/composer.json @@ -12,19 +12,21 @@ "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" }, "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", "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 e4255dbd..292fcc76 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": "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.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "reference": "e3b85d6e36ad00e5db2d1dcc27c81ffdf15cbf76", + "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-02-07T17:19:31+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.1", "source": { "type": "git", - "url": "https://github.com/paratestphp/paratest.git", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9" + "url": "https://github.com/amphp/amp.git", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paratestphp/paratest/zipball/53cb90a6aa3ef3840458781600628ade058a18b9", - "reference": "53cb90a6aa3ef3840458781600628ade058a18b9", + "url": "https://api.github.com/repos/amphp/amp/zipball/fa0ab33a6f47a82929c38d03ca47ebb71086a93f", + "reference": "fa0ab33a6f47a82929c38d03ca47ebb71086a93f", "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": "5.23.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.1" }, "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": "2025-08-27T21:42:00+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,58 +6759,1309 @@ ], "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.1", "source": { "type": "git", - "url": "https://github.com/filp/whoops.git", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + "url": "https://github.com/amphp/hpack.git", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", - "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "url": "https://api.github.com/repos/amphp/hpack/zipball/4f293064b15682a2b178b1367ddf0b8b5feb0239", + "reference": "4f293064b15682a2b178b1367ddf0b8b5feb0239", "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" - }, - "suggest": { - "symfony/var-dumper": "Pretty print complex values better with var-dumper available", - "whoops/soap": "Formats errors as SOAP responses" + "amphp/php-cs-fixer-config": "^2", + "http2jp/hpack-test-case": "^1", + "nikic/php-fuzzer": "^0.0.10", + "phpunit/phpunit": "^7 | ^8 | ^9" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "2.7-dev" + "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.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-03-21T19:00:16+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.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-client.git", + "reference": "75ad21574fd632594a2dd914496647816d5106bc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-client/zipball/75ad21574fd632594a2dd914496647816d5106bc", + "reference": "75ad21574fd632594a2dd914496647816d5106bc", + "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", + "laminas/laminas-diactoros": "^2.3", + "phpunit/phpunit": "^9", + "psalm/phar": "~5.23" + }, + "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.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-08-16T20:41:23+00:00" + }, + { + "name": "amphp/http-server", + "version": "v3.4.4", + "source": { + "type": "git", + "url": "https://github.com/amphp/http-server.git", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/http-server/zipball/8dc32cc6a65c12a3543276305796b993c56b76ef", + "reference": "8dc32cc6a65c12a3543276305796b993c56b76ef", + "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": "~5.23" + }, + "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.4" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2026-02-08T18:16:29+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.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "7b52598c2e9105ebcddf247fc523161581930367" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/7b52598c2e9105ebcddf247fc523161581930367", + "reference": "7b52598c2e9105ebcddf247fc523161581930367", + "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": "^5.18" + }, + "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.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2025-03-16T16:33:53+00:00" + }, + { + "name": "amphp/process", + "version": "v2.0.3", + "source": { + "type": "git", + "url": "https://github.com/amphp/process.git", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "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": "^5.4" + }, + "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.0.3" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:13:44+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.3.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "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.3.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-21T14:33:03+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" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" } }, "autoload": { @@ -6875,37 +8213,96 @@ }, "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": "v1.0.18", + "version": "v2.4.3", "source": { "type": "git", "url": "https://github.com/laravel/boost.git", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab" + "reference": "841d52905728cfac9f93c778a1758e740ce9a367" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/boost/zipball/df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", - "reference": "df2a62b5864759ea8cce8a4b7575b657e9c7d4ab", + "url": "https://api.github.com/repos/laravel/boost/zipball/841d52905728cfac9f93c778a1758e740ce9a367", + "reference": "841d52905728cfac9f93c778a1758e740ce9a367", "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.5.1|^0.6.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 +8324,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,35 +8335,41 @@ "issues": "https://github.com/laravel/boost/issues", "source": "https://github.com/laravel/boost" }, - "time": "2025-08-16T09:10:03+00:00" + "time": "2026-04-10T15:59:10+00:00" }, { "name": "laravel/mcp", - "version": "v0.1.1", + "version": "v0.6.5", "source": { "type": "git", "url": "https://github.com/laravel/mcp.git", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713" + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/mcp/zipball/6d6284a491f07c74d34f48dfd999ed52c567c713", - "reference": "6d6284a491f07c74d34f48dfd999ed52c567c713", + "url": "https://api.github.com/repos/laravel/mcp/zipball/583a6282bf0f074d754f7ff5cd1fff9d34244691", + "reference": "583a6282bf0f074d754f7ff5cd1fff9d34244691", "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" }, "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": { @@ -6982,8 +8385,6 @@ "autoload": { "psr-4": { "Laravel\\Mcp\\": "src/", - "Workbench\\App\\": "workbench/app/", - "Laravel\\Mcp\\Tests\\": "tests/", "Laravel\\Mcp\\Server\\": "src/Server/" } }, @@ -6991,10 +8392,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 +8408,7 @@ "issues": "https://github.com/laravel/mcp/issues", "source": "https://github.com/laravel/mcp" }, - "time": "2025-08-16T09:50:43+00:00" + "time": "2026-03-30T19:17:10+00:00" }, { "name": "laravel/pail", @@ -7153,30 +8559,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 +8616,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", @@ -7274,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", @@ -7772,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", @@ -8769,6 +10343,78 @@ ], "time": "2026-01-27T06:12:29+00:00" }, + { + "name": "revolt/event-loop", + "version": "v1.0.8", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/b6fc06dce8e9b523c9946138fa5e62181934f91c", + "reference": "b6fc06dce8e9b523c9946138fa5e62181934f91c", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "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.8" + }, + "time": "2025-08-27T21:33:23+00:00" + }, { "name": "sebastian/cli-parser", "version": "4.2.0", @@ -9974,5 +11620,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/auth.php b/config/auth.php index 7d1eb0de..19f61403 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,6 +40,16 @@ 'driver' => 'session', 'provider' => 'users', ], + + 'customer' => [ + 'driver' => 'session', + 'provider' => 'customers', + ], + + 'sanctum' => [ + 'driver' => 'sanctum', + 'provider' => 'users', + ], ], /* @@ -65,10 +75,9 @@ 'model' => env('AUTH_MODEL', App\Models\User::class), ], - // 'users' => [ - // 'driver' => 'database', - // 'table' => 'users', - // ], + 'customers' => [ + 'driver' => 'customer', + ], ], /* @@ -97,6 +106,13 @@ 'expire' => 60, 'throttle' => 60, ], + + 'customers' => [ + 'provider' => 'customers', + 'table' => 'customer_password_reset_tokens', + 'expire' => 60, + 'throttle' => 60, + ], ], /* diff --git a/config/database.php b/config/database.php index df933e7f..5fc3bf71 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' => env('DB_BUSY_TIMEOUT', 5000), + 'journal_mode' => env('DB_JOURNAL_MODE', 'WAL'), + 'synchronous' => env('DB_SYNCHRONOUS', 'NORMAL'), 'transaction_mode' => 'DEFERRED', ], diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 00000000..44527d68 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,84 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class, + 'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class, + 'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class, + ], + +]; diff --git a/database/factories/AnalyticsDailyFactory.php b/database/factories/AnalyticsDailyFactory.php new file mode 100644 index 00000000..d40953ab --- /dev/null +++ b/database/factories/AnalyticsDailyFactory.php @@ -0,0 +1,30 @@ + + */ +class AnalyticsDailyFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'date' => now()->toDateString(), + 'orders_count' => 0, + 'revenue_amount' => 0, + 'aov_amount' => 0, + 'visits_count' => 0, + 'add_to_cart_count' => 0, + 'checkout_started_count' => 0, + 'checkout_completed_count' => 0, + ]; + } +} diff --git a/database/factories/AnalyticsEventFactory.php b/database/factories/AnalyticsEventFactory.php new file mode 100644 index 00000000..4bfc3bd1 --- /dev/null +++ b/database/factories/AnalyticsEventFactory.php @@ -0,0 +1,31 @@ + + */ +class AnalyticsEventFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => AnalyticsEventType::PageView->value, + 'session_id' => Str::random(16), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => null, + 'occurred_at' => now(), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppFactory.php b/database/factories/AppFactory.php new file mode 100644 index 00000000..448af140 --- /dev/null +++ b/database/factories/AppFactory.php @@ -0,0 +1,27 @@ + + */ +class AppFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->company(); + + return [ + 'name' => $name, + 'handle' => Str::slug($name).'-'.Str::random(4), + 'status' => 'active', + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/AppInstallationFactory.php b/database/factories/AppInstallationFactory.php new file mode 100644 index 00000000..7c092a52 --- /dev/null +++ b/database/factories/AppInstallationFactory.php @@ -0,0 +1,28 @@ + + */ +class AppInstallationFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_id' => App::factory(), + 'scopes_json' => [], + 'status' => 'active', + 'installed_at' => now(), + 'uninstalled_at' => null, + ]; + } +} diff --git a/database/factories/CartFactory.php b/database/factories/CartFactory.php new file mode 100644 index 00000000..4c0754ee --- /dev/null +++ b/database/factories/CartFactory.php @@ -0,0 +1,27 @@ + + */ +class CartFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'currency' => 'USD', + 'cart_version' => 1, + 'status' => CartStatus::Active->value, + ]; + } +} diff --git a/database/factories/CartLineFactory.php b/database/factories/CartLineFactory.php new file mode 100644 index 00000000..7a9d5cd0 --- /dev/null +++ b/database/factories/CartLineFactory.php @@ -0,0 +1,32 @@ + + */ +class CartLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $price = 1000; + $qty = 1; + + return [ + 'cart_id' => Cart::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity' => $qty, + 'unit_price_amount' => $price, + 'line_subtotal_amount' => $price * $qty, + 'line_discount_amount' => 0, + 'line_total_amount' => $price * $qty, + ]; + } +} diff --git a/database/factories/CheckoutFactory.php b/database/factories/CheckoutFactory.php new file mode 100644 index 00000000..8e39197e --- /dev/null +++ b/database/factories/CheckoutFactory.php @@ -0,0 +1,27 @@ + + */ +class CheckoutFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'cart_id' => Cart::factory(), + 'customer_id' => null, + 'status' => CheckoutStatus::Started->value, + ]; + } +} diff --git a/database/factories/CollectionFactory.php b/database/factories/CollectionFactory.php new file mode 100644 index 00000000..539df839 --- /dev/null +++ b/database/factories/CollectionFactory.php @@ -0,0 +1,42 @@ + + */ +class CollectionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucfirst($title), + 'handle' => Str::slug($title).'-'.Str::random(4), + 'description_html' => '

'.fake()->sentence().'

', + 'type' => CollectionType::Manual->value, + 'status' => CollectionStatus::Active->value, + ]; + } + + public function automated(): self + { + return $this->state(fn (): array => ['type' => CollectionType::Automated->value]); + } + + public function draft(): self + { + return $this->state(fn (): array => ['status' => CollectionStatus::Draft->value]); + } +} diff --git a/database/factories/CustomerAddressFactory.php b/database/factories/CustomerAddressFactory.php new file mode 100644 index 00000000..bec23764 --- /dev/null +++ b/database/factories/CustomerAddressFactory.php @@ -0,0 +1,33 @@ + + */ +class CustomerAddressFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'customer_id' => Customer::factory(), + 'label' => 'Home', + 'address_json' => [ + 'first_name' => fake()->firstName(), + 'last_name' => fake()->lastName(), + 'address1' => fake()->streetAddress(), + 'city' => fake()->city(), + 'province_code' => 'CA', + 'country_code' => 'US', + 'postal_code' => fake()->postcode(), + ], + 'is_default' => 1, + ]; + } +} diff --git a/database/factories/CustomerFactory.php b/database/factories/CustomerFactory.php new file mode 100644 index 00000000..86eb5112 --- /dev/null +++ b/database/factories/CustomerFactory.php @@ -0,0 +1,35 @@ + + */ +class CustomerFactory extends Factory +{ + protected static ?string $password; + + /** + * @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' => 0, + 'email_verified_at' => now(), + ]; + } + + public function guest(): self + { + return $this->state(fn (): array => ['password_hash' => null]); + } +} diff --git a/database/factories/DiscountFactory.php b/database/factories/DiscountFactory.php new file mode 100644 index 00000000..c577dd06 --- /dev/null +++ b/database/factories/DiscountFactory.php @@ -0,0 +1,76 @@ + + */ +class DiscountFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'type' => DiscountType::Code->value, + 'code' => strtoupper(Str::random(8)), + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 10, + 'starts_at' => now()->subHour(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'rules_json' => [], + 'status' => DiscountStatus::Active->value, + ]; + } + + public function percent(int $percent): self + { + return $this->state(fn (): array => [ + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => $percent, + ]); + } + + public function fixed(int $amountCents): self + { + return $this->state(fn (): array => [ + 'value_type' => DiscountValueType::Fixed->value, + 'value_amount' => $amountCents, + ]); + } + + public function freeShipping(): self + { + return $this->state(fn (): array => [ + 'value_type' => DiscountValueType::FreeShipping->value, + 'value_amount' => 0, + ]); + } + + public function expired(): self + { + return $this->state(fn (): array => [ + 'starts_at' => now()->subDays(30), + 'ends_at' => now()->subDays(1), + ]); + } + + public function exhausted(): self + { + return $this->state(fn (): array => [ + 'usage_limit' => 1, + 'usage_count' => 1, + ]); + } +} diff --git a/database/factories/FulfillmentFactory.php b/database/factories/FulfillmentFactory.php new file mode 100644 index 00000000..6bd2ec83 --- /dev/null +++ b/database/factories/FulfillmentFactory.php @@ -0,0 +1,29 @@ + + */ +class FulfillmentFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'status' => FulfillmentShipmentStatus::Pending->value, + 'tracking_company' => null, + 'tracking_number' => null, + 'tracking_url' => null, + 'shipped_at' => null, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/FulfillmentLineFactory.php b/database/factories/FulfillmentLineFactory.php new file mode 100644 index 00000000..865d79fd --- /dev/null +++ b/database/factories/FulfillmentLineFactory.php @@ -0,0 +1,25 @@ + + */ +class FulfillmentLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'fulfillment_id' => Fulfillment::factory(), + 'order_line_id' => OrderLine::factory(), + 'quantity' => 1, + ]; + } +} diff --git a/database/factories/InventoryItemFactory.php b/database/factories/InventoryItemFactory.php new file mode 100644 index 00000000..db746ed6 --- /dev/null +++ b/database/factories/InventoryItemFactory.php @@ -0,0 +1,33 @@ + + */ +class InventoryItemFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'variant_id' => ProductVariant::factory(), + 'quantity_on_hand' => fake()->numberBetween(0, 100), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]; + } + + public function continuePolicy(): self + { + return $this->state(fn (): array => ['policy' => InventoryPolicy::Continue->value]); + } +} diff --git a/database/factories/NavigationItemFactory.php b/database/factories/NavigationItemFactory.php new file mode 100644 index 00000000..551722ab --- /dev/null +++ b/database/factories/NavigationItemFactory.php @@ -0,0 +1,28 @@ + + */ +class NavigationItemFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'menu_id' => NavigationMenu::factory(), + 'type' => NavigationItemType::Link->value, + 'label' => ucfirst(fake()->words(2, true)), + 'url' => '/'.fake()->slug(), + 'resource_id' => null, + 'position' => 0, + ]; + } +} diff --git a/database/factories/NavigationMenuFactory.php b/database/factories/NavigationMenuFactory.php new file mode 100644 index 00000000..8339efc4 --- /dev/null +++ b/database/factories/NavigationMenuFactory.php @@ -0,0 +1,27 @@ + + */ +class NavigationMenuFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(2, true); + + return [ + 'store_id' => Store::factory(), + 'handle' => Str::slug($title).'-'.Str::random(4), + 'title' => ucfirst($title), + ]; + } +} diff --git a/database/factories/OrderFactory.php b/database/factories/OrderFactory.php new file mode 100644 index 00000000..61b78417 --- /dev/null +++ b/database/factories/OrderFactory.php @@ -0,0 +1,50 @@ + + */ +class OrderFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'customer_id' => null, + 'order_number' => '#'.fake()->unique()->numberBetween(1000, 99999), + 'payment_method' => PaymentMethod::CreditCard->value, + 'status' => OrderStatus::Pending->value, + 'financial_status' => FinancialStatus::Pending->value, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled->value, + 'currency' => 'USD', + 'subtotal_amount' => 1000, + 'discount_amount' => 0, + 'shipping_amount' => 0, + 'tax_amount' => 0, + 'total_amount' => 1000, + 'email' => fake()->safeEmail(), + 'billing_address_json' => null, + 'shipping_address_json' => null, + 'placed_at' => now(), + ]; + } + + public function paid(): self + { + return $this->state(fn (): array => [ + 'status' => OrderStatus::Paid->value, + 'financial_status' => FinancialStatus::Paid->value, + ]); + } +} diff --git a/database/factories/OrderLineFactory.php b/database/factories/OrderLineFactory.php new file mode 100644 index 00000000..eda1c6d1 --- /dev/null +++ b/database/factories/OrderLineFactory.php @@ -0,0 +1,31 @@ + + */ +class OrderLineFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'product_id' => null, + 'variant_id' => null, + 'title_snapshot' => fake()->words(3, true), + 'sku_snapshot' => strtoupper(fake()->bothify('???-####')), + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'total_amount' => 1000, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]; + } +} diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php new file mode 100644 index 00000000..692cc404 --- /dev/null +++ b/database/factories/OrganizationFactory.php @@ -0,0 +1,22 @@ + + */ +class OrganizationFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->company(), + 'billing_email' => fake()->unique()->companyEmail(), + ]; + } +} diff --git a/database/factories/PageFactory.php b/database/factories/PageFactory.php new file mode 100644 index 00000000..1b289fd2 --- /dev/null +++ b/database/factories/PageFactory.php @@ -0,0 +1,46 @@ + + */ +class PageFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->sentence(3); + + return [ + 'store_id' => Store::factory(), + 'title' => rtrim($title, '.'), + 'handle' => Str::slug($title).'-'.Str::random(4), + 'body_html' => '

'.fake()->paragraph().'

', + 'status' => PageStatus::Published->value, + 'published_at' => now(), + ]; + } + + public function draft(): self + { + return $this->state(fn (): array => [ + 'status' => PageStatus::Draft->value, + 'published_at' => null, + ]); + } + + public function archived(): self + { + return $this->state(fn (): array => [ + 'status' => PageStatus::Archived->value, + ]); + } +} diff --git a/database/factories/PaymentFactory.php b/database/factories/PaymentFactory.php new file mode 100644 index 00000000..f115615b --- /dev/null +++ b/database/factories/PaymentFactory.php @@ -0,0 +1,33 @@ + + */ +class PaymentFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'provider' => 'mock', + 'method' => PaymentMethod::CreditCard->value, + 'provider_payment_id' => 'mock_'.Str::random(12), + 'status' => PaymentStatus::Captured->value, + 'amount' => 1000, + 'currency' => 'USD', + 'raw_json_encrypted' => null, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php new file mode 100644 index 00000000..7430351d --- /dev/null +++ b/database/factories/ProductFactory.php @@ -0,0 +1,49 @@ + + */ +class ProductFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $title = fake()->unique()->words(3, true); + + return [ + 'store_id' => Store::factory(), + 'title' => ucfirst($title), + 'handle' => Str::slug($title).'-'.Str::random(4), + 'status' => ProductStatus::Draft->value, + 'description_html' => '

'.fake()->paragraph().'

', + 'vendor' => fake()->company(), + 'product_type' => fake()->randomElement(['Apparel', 'Footwear', 'Accessories', 'Home']), + 'tags' => [], + 'published_at' => null, + ]; + } + + public function active(): self + { + return $this->state(fn (): array => [ + 'status' => ProductStatus::Active->value, + 'published_at' => now(), + ]); + } + + public function archived(): self + { + return $this->state(fn (): array => [ + 'status' => ProductStatus::Archived->value, + ]); + } +} diff --git a/database/factories/ProductMediaFactory.php b/database/factories/ProductMediaFactory.php new file mode 100644 index 00000000..08fb3328 --- /dev/null +++ b/database/factories/ProductMediaFactory.php @@ -0,0 +1,40 @@ + + */ +class ProductMediaFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'type' => MediaType::Image->value, + 'storage_key' => 'media/originals/'.Str::random(24).'.jpg', + 'alt_text' => fake()->sentence(4), + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => fake()->numberBetween(50_000, 500_000), + 'position' => 0, + 'status' => MediaStatus::Ready->value, + 'created_at' => now(), + ]; + } + + public function processing(): self + { + return $this->state(fn (): array => ['status' => MediaStatus::Processing->value]); + } +} diff --git a/database/factories/ProductOptionFactory.php b/database/factories/ProductOptionFactory.php new file mode 100644 index 00000000..537b41d9 --- /dev/null +++ b/database/factories/ProductOptionFactory.php @@ -0,0 +1,24 @@ + + */ +class ProductOptionFactory extends Factory +{ + /** + * @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..185abd51 --- /dev/null +++ b/database/factories/ProductOptionValueFactory.php @@ -0,0 +1,24 @@ + + */ +class ProductOptionValueFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_option_id' => ProductOption::factory(), + 'value' => fake()->unique()->word(), + 'position' => 0, + ]; + } +} diff --git a/database/factories/ProductVariantFactory.php b/database/factories/ProductVariantFactory.php new file mode 100644 index 00000000..15aa0b42 --- /dev/null +++ b/database/factories/ProductVariantFactory.php @@ -0,0 +1,44 @@ + + */ +class ProductVariantFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'product_id' => Product::factory(), + 'sku' => strtoupper(Str::random(8)), + 'barcode' => null, + 'price_amount' => fake()->numberBetween(500, 50000), + 'compare_at_amount' => null, + 'currency' => 'USD', + 'weight_g' => fake()->numberBetween(50, 2000), + 'requires_shipping' => 1, + 'is_default' => 0, + 'position' => 0, + 'status' => VariantStatus::Active->value, + ]; + } + + public function default(): self + { + return $this->state(fn (): array => ['is_default' => 1]); + } + + public function archived(): self + { + return $this->state(fn (): array => ['status' => VariantStatus::Archived->value]); + } +} diff --git a/database/factories/RefundFactory.php b/database/factories/RefundFactory.php new file mode 100644 index 00000000..dfbc8f69 --- /dev/null +++ b/database/factories/RefundFactory.php @@ -0,0 +1,30 @@ + + */ +class RefundFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'order_id' => Order::factory(), + 'payment_id' => Payment::factory(), + 'amount' => 500, + 'reason' => 'test', + 'status' => RefundStatus::Processed->value, + 'provider_refund_id' => 'mock_refund_'.fake()->unique()->word(), + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/SearchQueryFactory.php b/database/factories/SearchQueryFactory.php new file mode 100644 index 00000000..fcee2ae3 --- /dev/null +++ b/database/factories/SearchQueryFactory.php @@ -0,0 +1,27 @@ + + */ +class SearchQueryFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'query' => fake()->word(), + 'filters_json' => null, + 'results_count' => 0, + 'session_id' => null, + 'created_at' => now(), + ]; + } +} diff --git a/database/factories/ShippingRateFactory.php b/database/factories/ShippingRateFactory.php new file mode 100644 index 00000000..e285fd49 --- /dev/null +++ b/database/factories/ShippingRateFactory.php @@ -0,0 +1,38 @@ + + */ +class ShippingRateFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'zone_id' => ShippingZone::factory(), + 'name' => 'Standard', + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 799], + 'is_active' => 1, + ]; + } + + public function weight(): self + { + return $this->state(fn (): array => [ + 'type' => ShippingRateType::Weight->value, + 'config_json' => ['ranges' => [ + ['min_g' => 0, 'max_g' => 1000, 'amount' => 499], + ['min_g' => 1001, 'max_g' => 5000, 'amount' => 999], + ]], + ]); + } +} diff --git a/database/factories/ShippingZoneFactory.php b/database/factories/ShippingZoneFactory.php new file mode 100644 index 00000000..fda51282 --- /dev/null +++ b/database/factories/ShippingZoneFactory.php @@ -0,0 +1,25 @@ + + */ +class ShippingZoneFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->unique()->country(), + 'countries_json' => ['US'], + 'regions_json' => [], + ]; + } +} diff --git a/database/factories/StoreDomainFactory.php b/database/factories/StoreDomainFactory.php new file mode 100644 index 00000000..bf8780b1 --- /dev/null +++ b/database/factories/StoreDomainFactory.php @@ -0,0 +1,33 @@ + + */ +class StoreDomainFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'hostname' => fake()->unique()->domainName(), + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + 'tls_mode' => 'managed', + 'created_at' => now(), + ]; + } + + public function admin(): self + { + return $this->state(fn (): array => ['type' => StoreDomainType::Admin->value]); + } +} diff --git a/database/factories/StoreFactory.php b/database/factories/StoreFactory.php new file mode 100644 index 00000000..cd23ecea --- /dev/null +++ b/database/factories/StoreFactory.php @@ -0,0 +1,37 @@ + + */ +class StoreFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $name = fake()->unique()->company(); + + return [ + 'organization_id' => Organization::factory(), + 'name' => $name, + 'handle' => Str::slug($name).'-'.Str::random(4), + 'status' => StoreStatus::Active->value, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ]; + } + + public function suspended(): self + { + return $this->state(fn (): array => ['status' => StoreStatus::Suspended->value]); + } +} diff --git a/database/factories/StoreSettingsFactory.php b/database/factories/StoreSettingsFactory.php new file mode 100644 index 00000000..a72f7048 --- /dev/null +++ b/database/factories/StoreSettingsFactory.php @@ -0,0 +1,23 @@ + + */ +class StoreSettingsFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'settings_json' => '{}', + ]; + } +} diff --git a/database/factories/TaxSettingsFactory.php b/database/factories/TaxSettingsFactory.php new file mode 100644 index 00000000..fb2b4e91 --- /dev/null +++ b/database/factories/TaxSettingsFactory.php @@ -0,0 +1,28 @@ + + */ +class TaxSettingsFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'mode' => TaxMode::Manual->value, + 'provider' => TaxProviderType::None->value, + 'prices_include_tax' => 0, + 'config_json' => ['default_rate_bps' => 0], + ]; + } +} diff --git a/database/factories/ThemeFactory.php b/database/factories/ThemeFactory.php new file mode 100644 index 00000000..ba594e4e --- /dev/null +++ b/database/factories/ThemeFactory.php @@ -0,0 +1,35 @@ + + */ +class ThemeFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'name' => fake()->unique()->words(2, true), + 'version' => '1.0.0', + 'status' => ThemeStatus::Draft->value, + 'published_at' => null, + ]; + } + + public function published(): self + { + return $this->state(fn (): array => [ + 'status' => ThemeStatus::Published->value, + 'published_at' => now(), + ]); + } +} diff --git a/database/factories/ThemeFileFactory.php b/database/factories/ThemeFileFactory.php new file mode 100644 index 00000000..91168924 --- /dev/null +++ b/database/factories/ThemeFileFactory.php @@ -0,0 +1,27 @@ + + */ +class ThemeFileFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'path' => 'templates/'.fake()->unique()->word().'.blade.php', + 'storage_key' => 'themes/'.Str::random(16), + 'sha256' => hash('sha256', Str::random(32)), + 'byte_size' => fake()->numberBetween(100, 10000), + ]; + } +} diff --git a/database/factories/ThemeSettingsFactory.php b/database/factories/ThemeSettingsFactory.php new file mode 100644 index 00000000..fdd2f4a1 --- /dev/null +++ b/database/factories/ThemeSettingsFactory.php @@ -0,0 +1,23 @@ + + */ +class ThemeSettingsFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'theme_id' => Theme::factory(), + 'settings_json' => '{}', + ]; + } +} diff --git a/database/factories/WebhookDeliveryFactory.php b/database/factories/WebhookDeliveryFactory.php new file mode 100644 index 00000000..f3ebcfc6 --- /dev/null +++ b/database/factories/WebhookDeliveryFactory.php @@ -0,0 +1,30 @@ + + */ +class WebhookDeliveryFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'subscription_id' => WebhookSubscription::factory(), + 'event_id' => (string) Str::uuid(), + 'attempt_count' => 1, + 'status' => 'pending', + 'last_attempt_at' => null, + 'next_retry_at' => null, + 'response_code' => null, + 'response_body_snippet' => null, + ]; + } +} diff --git a/database/factories/WebhookSubscriptionFactory.php b/database/factories/WebhookSubscriptionFactory.php new file mode 100644 index 00000000..75e39cb0 --- /dev/null +++ b/database/factories/WebhookSubscriptionFactory.php @@ -0,0 +1,31 @@ + + */ +class WebhookSubscriptionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'store_id' => Store::factory(), + 'app_installation_id' => null, + 'event_type' => WebhookTopic::OrderPaid->value, + 'target_url' => 'https://example.test/webhook/'.Str::random(8), + 'signing_secret_encrypted' => Str::random(40), + 'status' => 'active', + 'consecutive_failures' => 0, + 'created_at' => now(), + ]; + } +} diff --git a/database/migrations/2026_04_17_170701_create_organizations_table.php b/database/migrations/2026_04_17_170701_create_organizations_table.php new file mode 100644 index 00000000..4eb2eab2 --- /dev/null +++ b/database/migrations/2026_04_17_170701_create_organizations_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('name'); + $table->string('billing_email'); + $table->timestamps(); + + $table->index('billing_email', 'idx_organizations_billing_email'); + }); + } + + public function down(): void + { + Schema::dropIfExists('organizations'); + } +}; diff --git a/database/migrations/2026_04_17_170702_create_stores_table.php b/database/migrations/2026_04_17_170702_create_stores_table.php new file mode 100644 index 00000000..4db11467 --- /dev/null +++ b/database/migrations/2026_04_17_170702_create_stores_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('organization_id')->constrained('organizations')->cascadeOnDelete(); + $table->string('name'); + $table->string('handle')->unique('idx_stores_handle'); + $table->string('status')->default(StoreStatus::Active->value); + $table->string('default_currency', 3)->default('USD'); + $table->string('default_locale', 10)->default('en'); + $table->string('timezone')->default('UTC'); + $table->timestamps(); + + $table->index('organization_id', 'idx_stores_organization_id'); + $table->index('status', 'idx_stores_status'); + }); + + $allowed = collect(StoreStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER stores_status_check_insert BEFORE INSERT ON stores FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER stores_status_check_update BEFORE UPDATE ON stores FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS stores_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS stores_status_check_update'); + Schema::dropIfExists('stores'); + } +}; diff --git a/database/migrations/2026_04_17_170703_create_store_domains_table.php b/database/migrations/2026_04_17_170703_create_store_domains_table.php new file mode 100644 index 00000000..38c76a8d --- /dev/null +++ b/database/migrations/2026_04_17_170703_create_store_domains_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('hostname')->unique('idx_store_domains_hostname'); + $table->string('type')->default(StoreDomainType::Storefront->value); + $table->unsignedTinyInteger('is_primary')->default(0); + $table->string('tls_mode')->default('managed'); + $table->timestamp('created_at')->nullable(); + + $table->index('store_id', 'idx_store_domains_store_id'); + $table->index(['store_id', 'is_primary'], 'idx_store_domains_store_primary'); + }); + + $types = collect(StoreDomainType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $tls = "'managed','bring_your_own'"; + + DB::statement("CREATE TRIGGER store_domains_type_check_insert BEFORE INSERT ON store_domains FOR EACH ROW WHEN NEW.type NOT IN ({$types}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER store_domains_type_check_update BEFORE UPDATE ON store_domains FOR EACH ROW WHEN NEW.type NOT IN ({$types}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER store_domains_tls_check_insert BEFORE INSERT ON store_domains FOR EACH ROW WHEN NEW.tls_mode NOT IN ({$tls}) BEGIN SELECT RAISE(ABORT, 'invalid tls_mode'); END"); + DB::statement("CREATE TRIGGER store_domains_tls_check_update BEFORE UPDATE ON store_domains FOR EACH ROW WHEN NEW.tls_mode NOT IN ({$tls}) BEGIN SELECT RAISE(ABORT, 'invalid tls_mode'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS store_domains_type_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS store_domains_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS store_domains_tls_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS store_domains_tls_check_update'); + Schema::dropIfExists('store_domains'); + } +}; diff --git a/database/migrations/2026_04_17_170704_add_shop_columns_to_users_table.php b/database/migrations/2026_04_17_170704_add_shop_columns_to_users_table.php new file mode 100644 index 00000000..ac7b2ebd --- /dev/null +++ b/database/migrations/2026_04_17_170704_add_shop_columns_to_users_table.php @@ -0,0 +1,36 @@ +string('status')->default('active')->after('password'); + $table->timestamp('last_login_at')->nullable()->after('email_verified_at'); + }); + + Schema::table('users', function (Blueprint $table): void { + $table->index('status', 'idx_users_status'); + }); + + $statuses = "'active','disabled'"; + DB::statement("CREATE TRIGGER users_status_check_insert BEFORE INSERT ON users FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER users_status_check_update BEFORE UPDATE ON users FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS users_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS users_status_check_update'); + + Schema::table('users', function (Blueprint $table): void { + $table->dropIndex('idx_users_status'); + $table->dropColumn(['status', 'last_login_at']); + }); + } +}; diff --git a/database/migrations/2026_04_17_170705_create_store_users_table.php b/database/migrations/2026_04_17_170705_create_store_users_table.php new file mode 100644 index 00000000..103056f9 --- /dev/null +++ b/database/migrations/2026_04_17_170705_create_store_users_table.php @@ -0,0 +1,35 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role')->default(StoreUserRole::Staff->value); + $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'); + }); + + $roles = collect(StoreUserRole::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER store_users_role_check_insert BEFORE INSERT ON store_users FOR EACH ROW WHEN NEW.role NOT IN ({$roles}) BEGIN SELECT RAISE(ABORT, 'invalid role'); END"); + DB::statement("CREATE TRIGGER store_users_role_check_update BEFORE UPDATE ON store_users FOR EACH ROW WHEN NEW.role NOT IN ({$roles}) BEGIN SELECT RAISE(ABORT, 'invalid role'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS store_users_role_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS store_users_role_check_update'); + Schema::dropIfExists('store_users'); + } +}; diff --git a/database/migrations/2026_04_17_170706_create_store_settings_table.php b/database/migrations/2026_04_17_170706_create_store_settings_table.php new file mode 100644 index 00000000..480328c3 --- /dev/null +++ b/database/migrations/2026_04_17_170706_create_store_settings_table.php @@ -0,0 +1,22 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('store_settings'); + } +}; diff --git a/database/migrations/2026_04_17_170823_create_customer_password_reset_tokens_table.php b/database/migrations/2026_04_17_170823_create_customer_password_reset_tokens_table.php new file mode 100644 index 00000000..f10f3b9a --- /dev/null +++ b/database/migrations/2026_04_17_170823_create_customer_password_reset_tokens_table.php @@ -0,0 +1,26 @@ +string('email'); + $table->unsignedBigInteger('store_id'); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + + $table->primary(['email', 'store_id']); + $table->index('store_id', 'idx_customer_password_reset_tokens_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_password_reset_tokens'); + } +}; diff --git a/database/migrations/2026_04_17_172630_create_themes_table.php b/database/migrations/2026_04_17_172630_create_themes_table.php new file mode 100644 index 00000000..903c20ea --- /dev/null +++ b/database/migrations/2026_04_17_172630_create_themes_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->string('version')->nullable(); + $table->string('status')->default(ThemeStatus::Draft->value); + $table->timestamp('published_at')->nullable(); + $table->timestamps(); + + $table->index('store_id', 'idx_themes_store_id'); + $table->index(['store_id', 'status'], 'idx_themes_store_status'); + }); + + $allowed = collect(ThemeStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER themes_status_check_insert BEFORE INSERT ON themes FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER themes_status_check_update BEFORE UPDATE ON themes FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS themes_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS themes_status_check_update'); + Schema::dropIfExists('themes'); + } +}; diff --git a/database/migrations/2026_04_17_172632_create_navigation_menus_table.php b/database/migrations/2026_04_17_172632_create_navigation_menus_table.php new file mode 100644 index 00000000..f2e4c5a0 --- /dev/null +++ b/database/migrations/2026_04_17_172632_create_navigation_menus_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('handle'); + $table->string('title'); + $table->timestamps(); + + $table->unique(['store_id', 'handle'], 'idx_navigation_menus_store_handle'); + $table->index('store_id', 'idx_navigation_menus_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('navigation_menus'); + } +}; diff --git a/database/migrations/2026_04_17_172632_create_theme_files_table.php b/database/migrations/2026_04_17_172632_create_theme_files_table.php new file mode 100644 index 00000000..fbc1e880 --- /dev/null +++ b/database/migrations/2026_04_17_172632_create_theme_files_table.php @@ -0,0 +1,28 @@ +id(); + $table->foreignId('theme_id')->constrained('themes')->cascadeOnDelete(); + $table->string('path'); + $table->string('storage_key'); + $table->string('sha256'); + $table->unsignedBigInteger('byte_size')->default(0); + + $table->unique(['theme_id', 'path'], 'idx_theme_files_theme_path'); + $table->index('theme_id', 'idx_theme_files_theme_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_files'); + } +}; diff --git a/database/migrations/2026_04_17_172633_create_navigation_items_table.php b/database/migrations/2026_04_17_172633_create_navigation_items_table.php new file mode 100644 index 00000000..eaa32e05 --- /dev/null +++ b/database/migrations/2026_04_17_172633_create_navigation_items_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('menu_id')->constrained('navigation_menus')->cascadeOnDelete(); + $table->string('type')->default(NavigationItemType::Link->value); + $table->string('label'); + $table->string('url')->nullable(); + $table->unsignedBigInteger('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'); + }); + + $allowed = collect(NavigationItemType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER navigation_items_type_check_insert BEFORE INSERT ON navigation_items FOR EACH ROW WHEN NEW.type NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER navigation_items_type_check_update BEFORE UPDATE ON navigation_items FOR EACH ROW WHEN NEW.type NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS navigation_items_type_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS navigation_items_type_check_update'); + Schema::dropIfExists('navigation_items'); + } +}; diff --git a/database/migrations/2026_04_17_172633_create_theme_settings_table.php b/database/migrations/2026_04_17_172633_create_theme_settings_table.php new file mode 100644 index 00000000..0d2e6822 --- /dev/null +++ b/database/migrations/2026_04_17_172633_create_theme_settings_table.php @@ -0,0 +1,22 @@ +foreignId('theme_id')->primary()->constrained('themes')->cascadeOnDelete(); + $table->text('settings_json')->default('{}'); + $table->timestamp('updated_at')->nullable(); + }); + } + + public function down(): void + { + Schema::dropIfExists('theme_settings'); + } +}; diff --git a/database/migrations/2026_04_17_172634_create_pages_table.php b/database/migrations/2026_04_17_172634_create_pages_table.php new file mode 100644 index 00000000..3e88f122 --- /dev/null +++ b/database/migrations/2026_04_17_172634_create_pages_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('body_html')->nullable(); + $table->string('status')->default(PageStatus::Draft->value); + $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'); + }); + + $allowed = collect(PageStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER pages_status_check_insert BEFORE INSERT ON pages FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER pages_status_check_update BEFORE UPDATE ON pages FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS pages_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS pages_status_check_update'); + Schema::dropIfExists('pages'); + } +}; diff --git a/database/migrations/2026_04_17_192701_create_products_table.php b/database/migrations/2026_04_17_192701_create_products_table.php new file mode 100644 index 00000000..a614e5bf --- /dev/null +++ b/database/migrations/2026_04_17_192701_create_products_table.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->string('status')->default(ProductStatus::Draft->value); + $table->text('description_html')->nullable(); + $table->string('vendor')->nullable(); + $table->string('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'); + }); + + $allowed = collect(ProductStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER products_status_check_insert BEFORE INSERT ON products FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER products_status_check_update BEFORE UPDATE ON products FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS products_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS products_status_check_update'); + Schema::dropIfExists('products'); + } +}; diff --git a/database/migrations/2026_04_17_192702_create_product_options_table.php b/database/migrations/2026_04_17_192702_create_product_options_table.php new file mode 100644 index 00000000..fdf6bbf4 --- /dev/null +++ b/database/migrations/2026_04_17_192702_create_product_options_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('name'); + $table->unsignedInteger('position')->default(0); + + $table->index('product_id', 'idx_product_options_product_id'); + $table->unique(['product_id', 'position'], 'idx_product_options_product_position'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_options'); + } +}; diff --git a/database/migrations/2026_04_17_192703_create_product_option_values_table.php b/database/migrations/2026_04_17_192703_create_product_option_values_table.php new file mode 100644 index 00000000..255b4ac5 --- /dev/null +++ b/database/migrations/2026_04_17_192703_create_product_option_values_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('product_option_id')->constrained('product_options')->cascadeOnDelete(); + $table->string('value'); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('product_option_values'); + } +}; diff --git a/database/migrations/2026_04_17_192704_create_product_variants_table.php b/database/migrations/2026_04_17_192704_create_product_variants_table.php new file mode 100644 index 00000000..a2ac5fab --- /dev/null +++ b/database/migrations/2026_04_17_192704_create_product_variants_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('sku')->nullable(); + $table->string('barcode')->nullable(); + $table->unsignedBigInteger('price_amount')->default(0); + $table->unsignedBigInteger('compare_at_amount')->nullable(); + $table->string('currency', 3)->default('USD'); + $table->unsignedInteger('weight_g')->nullable(); + $table->unsignedTinyInteger('requires_shipping')->default(1); + $table->unsignedTinyInteger('is_default')->default(0); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default(VariantStatus::Active->value); + $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'); + }); + + $allowed = collect(VariantStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER product_variants_status_check_insert BEFORE INSERT ON product_variants FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER product_variants_status_check_update BEFORE UPDATE ON product_variants FOR EACH ROW WHEN NEW.status NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS product_variants_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS product_variants_status_check_update'); + Schema::dropIfExists('product_variants'); + } +}; diff --git a/database/migrations/2026_04_17_192705_create_variant_option_values_table.php b/database/migrations/2026_04_17_192705_create_variant_option_values_table.php new file mode 100644 index 00000000..3cc58ff1 --- /dev/null +++ b/database/migrations/2026_04_17_192705_create_variant_option_values_table.php @@ -0,0 +1,24 @@ +foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->foreignId('product_option_value_id')->constrained('product_option_values')->cascadeOnDelete(); + + $table->primary(['variant_id', 'product_option_value_id']); + $table->index('product_option_value_id', 'idx_variant_option_values_value_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('variant_option_values'); + } +}; diff --git a/database/migrations/2026_04_17_192706_create_inventory_items_table.php b/database/migrations/2026_04_17_192706_create_inventory_items_table.php new file mode 100644 index 00000000..f56ea862 --- /dev/null +++ b/database/migrations/2026_04_17_192706_create_inventory_items_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('variant_id')->constrained('product_variants')->cascadeOnDelete(); + $table->integer('quantity_on_hand')->default(0); + $table->integer('quantity_reserved')->default(0); + $table->string('policy')->default(InventoryPolicy::Deny->value); + + $table->unique('variant_id', 'idx_inventory_items_variant_id'); + $table->index('store_id', 'idx_inventory_items_store_id'); + }); + + $allowed = collect(InventoryPolicy::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER inventory_items_policy_check_insert BEFORE INSERT ON inventory_items FOR EACH ROW WHEN NEW.policy NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid policy'); END"); + DB::statement("CREATE TRIGGER inventory_items_policy_check_update BEFORE UPDATE ON inventory_items FOR EACH ROW WHEN NEW.policy NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid policy'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS inventory_items_policy_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS inventory_items_policy_check_update'); + Schema::dropIfExists('inventory_items'); + } +}; diff --git a/database/migrations/2026_04_17_192707_create_collections_table.php b/database/migrations/2026_04_17_192707_create_collections_table.php new file mode 100644 index 00000000..3780f083 --- /dev/null +++ b/database/migrations/2026_04_17_192707_create_collections_table.php @@ -0,0 +1,46 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('title'); + $table->string('handle'); + $table->text('description_html')->nullable(); + $table->string('type')->default(CollectionType::Manual->value); + $table->string('status')->default(CollectionStatus::Active->value); + $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'); + }); + + $statusAllowed = collect(CollectionStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $typeAllowed = collect(CollectionType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + DB::statement("CREATE TRIGGER collections_status_check_insert BEFORE INSERT ON collections FOR EACH ROW WHEN NEW.status NOT IN ({$statusAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER collections_status_check_update BEFORE UPDATE ON collections FOR EACH ROW WHEN NEW.status NOT IN ({$statusAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER collections_type_check_insert BEFORE INSERT ON collections FOR EACH ROW WHEN NEW.type NOT IN ({$typeAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER collections_type_check_update BEFORE UPDATE ON collections FOR EACH ROW WHEN NEW.type NOT IN ({$typeAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS collections_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS collections_status_check_update'); + DB::statement('DROP TRIGGER IF EXISTS collections_type_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS collections_type_check_update'); + Schema::dropIfExists('collections'); + } +}; diff --git a/database/migrations/2026_04_17_192708_create_collection_products_table.php b/database/migrations/2026_04_17_192708_create_collection_products_table.php new file mode 100644 index 00000000..3b6718b7 --- /dev/null +++ b/database/migrations/2026_04_17_192708_create_collection_products_table.php @@ -0,0 +1,26 @@ +foreignId('collection_id')->constrained('collections')->cascadeOnDelete(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->unsignedInteger('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('collection_products'); + } +}; diff --git a/database/migrations/2026_04_17_192709_create_product_media_table.php b/database/migrations/2026_04_17_192709_create_product_media_table.php new file mode 100644 index 00000000..5a6e8fb0 --- /dev/null +++ b/database/migrations/2026_04_17_192709_create_product_media_table.php @@ -0,0 +1,50 @@ +id(); + $table->foreignId('product_id')->constrained('products')->cascadeOnDelete(); + $table->string('type')->default(MediaType::Image->value); + $table->string('storage_key'); + $table->string('alt_text')->nullable(); + $table->unsignedInteger('width')->nullable(); + $table->unsignedInteger('height')->nullable(); + $table->string('mime_type')->nullable(); + $table->unsignedBigInteger('byte_size')->nullable(); + $table->unsignedInteger('position')->default(0); + $table->string('status')->default(MediaStatus::Processing->value); + $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'); + }); + + $typeAllowed = collect(MediaType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $statusAllowed = collect(MediaStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + DB::statement("CREATE TRIGGER product_media_type_check_insert BEFORE INSERT ON product_media FOR EACH ROW WHEN NEW.type NOT IN ({$typeAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER product_media_type_check_update BEFORE UPDATE ON product_media FOR EACH ROW WHEN NEW.type NOT IN ({$typeAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER product_media_status_check_insert BEFORE INSERT ON product_media FOR EACH ROW WHEN NEW.status NOT IN ({$statusAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER product_media_status_check_update BEFORE UPDATE ON product_media FOR EACH ROW WHEN NEW.status NOT IN ({$statusAllowed}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS product_media_type_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS product_media_type_check_update'); + DB::statement('DROP TRIGGER IF EXISTS product_media_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS product_media_status_check_update'); + Schema::dropIfExists('product_media'); + } +}; diff --git a/database/migrations/2026_04_17_193801_create_search_queries_table.php b/database/migrations/2026_04_17_193801_create_search_queries_table.php new file mode 100644 index 00000000..628e4d4a --- /dev/null +++ b/database/migrations/2026_04_17_193801_create_search_queries_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('query'); + $table->text('filters_json')->nullable(); + $table->unsignedInteger('results_count')->default(0); + $table->string('session_id')->nullable(); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('search_queries'); + } +}; diff --git a/database/migrations/2026_04_17_193802_create_products_fts_table.php b/database/migrations/2026_04_17_193802_create_products_fts_table.php new file mode 100644 index 00000000..e94d0fea --- /dev/null +++ b/database/migrations/2026_04_17_193802_create_products_fts_table.php @@ -0,0 +1,81 @@ +", "> "), " ", " "), ""), + COALESCE(NEW.vendor, ""), + COALESCE(NEW.product_type, ""), + COALESCE(NEW.tags, "") + ); + END + '); + + DB::statement(' + CREATE TRIGGER products_ad_fts AFTER DELETE ON products BEGIN + DELETE FROM products_fts WHERE product_id = OLD.id; + END + '); + + DB::statement(' + CREATE TRIGGER products_au_fts AFTER UPDATE ON products BEGIN + DELETE FROM products_fts WHERE product_id = OLD.id; + INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags) + VALUES ( + NEW.store_id, + NEW.id, + COALESCE(NEW.title, ""), + COALESCE(REPLACE(REPLACE(REPLACE(NEW.description_html, "<", " <"), ">", "> "), " ", " "), ""), + COALESCE(NEW.vendor, ""), + COALESCE(NEW.product_type, ""), + COALESCE(NEW.tags, "") + ); + END + '); + + DB::statement(' + INSERT INTO products_fts (store_id, product_id, title, description, vendor, product_type, tags) + SELECT + store_id, + id, + COALESCE(title, ""), + COALESCE(REPLACE(REPLACE(REPLACE(description_html, "<", " <"), ">", "> "), " ", " "), ""), + COALESCE(vendor, ""), + COALESCE(product_type, ""), + COALESCE(tags, "") + FROM products + '); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS products_ai_fts'); + DB::statement('DROP TRIGGER IF EXISTS products_ad_fts'); + DB::statement('DROP TRIGGER IF EXISTS products_au_fts'); + DB::statement('DROP TABLE IF EXISTS products_fts'); + } +}; diff --git a/database/migrations/2026_04_17_200001_create_customers_table.php b/database/migrations/2026_04_17_200001_create_customers_table.php new file mode 100644 index 00000000..49b3c816 --- /dev/null +++ b/database/migrations/2026_04_17_200001_create_customers_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('email'); + $table->string('password_hash')->nullable(); + $table->string('name')->nullable(); + $table->unsignedTinyInteger('marketing_opt_in')->default(0); + $table->timestamp('email_verified_at')->nullable(); + $table->string('remember_token', 100)->nullable(); + $table->timestamps(); + + $table->unique(['store_id', 'email'], 'idx_customers_store_email'); + $table->index('store_id', 'idx_customers_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/database/migrations/2026_04_17_200002_create_customer_addresses_table.php b/database/migrations/2026_04_17_200002_create_customer_addresses_table.php new file mode 100644 index 00000000..138285ec --- /dev/null +++ b/database/migrations/2026_04_17_200002_create_customer_addresses_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('customer_id')->constrained('customers')->cascadeOnDelete(); + $table->string('label')->nullable(); + $table->text('address_json')->default('{}'); + $table->unsignedTinyInteger('is_default')->default(0); + + $table->index('customer_id', 'idx_customer_addresses_customer_id'); + $table->index(['customer_id', 'is_default'], 'idx_customer_addresses_default'); + }); + } + + public function down(): void + { + Schema::dropIfExists('customer_addresses'); + } +}; diff --git a/database/migrations/2026_04_17_200003_create_shipping_zones_table.php b/database/migrations/2026_04_17_200003_create_shipping_zones_table.php new file mode 100644 index 00000000..63c9571b --- /dev/null +++ b/database/migrations/2026_04_17_200003_create_shipping_zones_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('name'); + $table->text('countries_json')->default('[]'); + $table->text('regions_json')->default('[]'); + + $table->index('store_id', 'idx_shipping_zones_store_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('shipping_zones'); + } +}; diff --git a/database/migrations/2026_04_17_200004_create_shipping_rates_table.php b/database/migrations/2026_04_17_200004_create_shipping_rates_table.php new file mode 100644 index 00000000..cca34149 --- /dev/null +++ b/database/migrations/2026_04_17_200004_create_shipping_rates_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('zone_id')->constrained('shipping_zones')->cascadeOnDelete(); + $table->string('name'); + $table->string('type')->default(ShippingRateType::Flat->value); + $table->text('config_json')->default('{}'); + $table->unsignedTinyInteger('is_active')->default(1); + + $table->index('zone_id', 'idx_shipping_rates_zone_id'); + $table->index(['zone_id', 'is_active'], 'idx_shipping_rates_zone_active'); + }); + + $allowed = collect(ShippingRateType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER shipping_rates_type_check_insert BEFORE INSERT ON shipping_rates FOR EACH ROW WHEN NEW.type NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER shipping_rates_type_check_update BEFORE UPDATE ON shipping_rates FOR EACH ROW WHEN NEW.type NOT IN ({$allowed}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS shipping_rates_type_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS shipping_rates_type_check_update'); + Schema::dropIfExists('shipping_rates'); + } +}; diff --git a/database/migrations/2026_04_17_200005_create_tax_settings_table.php b/database/migrations/2026_04_17_200005_create_tax_settings_table.php new file mode 100644 index 00000000..008bb528 --- /dev/null +++ b/database/migrations/2026_04_17_200005_create_tax_settings_table.php @@ -0,0 +1,39 @@ +foreignId('store_id')->primary()->constrained('stores')->cascadeOnDelete(); + $table->string('mode')->default(TaxMode::Manual->value); + $table->string('provider')->default(TaxProviderType::None->value); + $table->unsignedTinyInteger('prices_include_tax')->default(0); + $table->text('config_json')->default('{}'); + }); + + $modes = collect(TaxMode::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $providers = collect(TaxProviderType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + DB::statement("CREATE TRIGGER tax_settings_mode_check_insert BEFORE INSERT ON tax_settings FOR EACH ROW WHEN NEW.mode NOT IN ({$modes}) BEGIN SELECT RAISE(ABORT, 'invalid mode'); END"); + DB::statement("CREATE TRIGGER tax_settings_mode_check_update BEFORE UPDATE ON tax_settings FOR EACH ROW WHEN NEW.mode NOT IN ({$modes}) BEGIN SELECT RAISE(ABORT, 'invalid mode'); END"); + DB::statement("CREATE TRIGGER tax_settings_provider_check_insert BEFORE INSERT ON tax_settings FOR EACH ROW WHEN NEW.provider NOT IN ({$providers}) BEGIN SELECT RAISE(ABORT, 'invalid provider'); END"); + DB::statement("CREATE TRIGGER tax_settings_provider_check_update BEFORE UPDATE ON tax_settings FOR EACH ROW WHEN NEW.provider NOT IN ({$providers}) BEGIN SELECT RAISE(ABORT, 'invalid provider'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS tax_settings_mode_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS tax_settings_mode_check_update'); + DB::statement('DROP TRIGGER IF EXISTS tax_settings_provider_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS tax_settings_provider_check_update'); + Schema::dropIfExists('tax_settings'); + } +}; diff --git a/database/migrations/2026_04_17_200006_create_discounts_table.php b/database/migrations/2026_04_17_200006_create_discounts_table.php new file mode 100644 index 00000000..f0a96c8d --- /dev/null +++ b/database/migrations/2026_04_17_200006_create_discounts_table.php @@ -0,0 +1,55 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('type')->default(DiscountType::Code->value); + $table->string('code')->nullable(); + $table->string('value_type'); + $table->integer('value_amount')->default(0); + $table->timestamp('starts_at'); + $table->timestamp('ends_at')->nullable(); + $table->integer('usage_limit')->nullable(); + $table->integer('usage_count')->default(0); + $table->text('rules_json')->default('{}'); + $table->string('status')->default(DiscountStatus::Active->value); + $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'); + }); + + $types = collect(DiscountType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $valueTypes = collect(DiscountValueType::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $statuses = collect(DiscountStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + DB::statement("CREATE TRIGGER discounts_type_check_insert BEFORE INSERT ON discounts FOR EACH ROW WHEN NEW.type NOT IN ({$types}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER discounts_type_check_update BEFORE UPDATE ON discounts FOR EACH ROW WHEN NEW.type NOT IN ({$types}) BEGIN SELECT RAISE(ABORT, 'invalid type'); END"); + DB::statement("CREATE TRIGGER discounts_value_type_check_insert BEFORE INSERT ON discounts FOR EACH ROW WHEN NEW.value_type NOT IN ({$valueTypes}) BEGIN SELECT RAISE(ABORT, 'invalid value_type'); END"); + DB::statement("CREATE TRIGGER discounts_value_type_check_update BEFORE UPDATE ON discounts FOR EACH ROW WHEN NEW.value_type NOT IN ({$valueTypes}) BEGIN SELECT RAISE(ABORT, 'invalid value_type'); END"); + DB::statement("CREATE TRIGGER discounts_status_check_insert BEFORE INSERT ON discounts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER discounts_status_check_update BEFORE UPDATE ON discounts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + foreach (['discounts_type_check_insert', 'discounts_type_check_update', 'discounts_value_type_check_insert', 'discounts_value_type_check_update', 'discounts_status_check_insert', 'discounts_status_check_update'] as $trigger) { + DB::statement("DROP TRIGGER IF EXISTS {$trigger}"); + } + Schema::dropIfExists('discounts'); + } +}; diff --git a/database/migrations/2026_04_17_200007_create_carts_table.php b/database/migrations/2026_04_17_200007_create_carts_table.php new file mode 100644 index 00000000..30ccf84e --- /dev/null +++ b/database/migrations/2026_04_17_200007_create_carts_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('currency', 3)->default('USD'); + $table->integer('cart_version')->default(1); + $table->string('status')->default(CartStatus::Active->value); + $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'); + }); + + $statuses = collect(CartStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + DB::statement("CREATE TRIGGER carts_status_check_insert BEFORE INSERT ON carts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER carts_status_check_update BEFORE UPDATE ON carts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS carts_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS carts_status_check_update'); + Schema::dropIfExists('carts'); + } +}; diff --git a/database/migrations/2026_04_17_200008_create_cart_lines_table.php b/database/migrations/2026_04_17_200008_create_cart_lines_table.php new file mode 100644 index 00000000..9fe4886f --- /dev/null +++ b/database/migrations/2026_04_17_200008_create_cart_lines_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('cart_id')->constrained('carts')->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('cart_lines'); + } +}; diff --git a/database/migrations/2026_04_17_200009_create_checkouts_table.php b/database/migrations/2026_04_17_200009_create_checkouts_table.php new file mode 100644 index 00000000..a3dc08d9 --- /dev/null +++ b/database/migrations/2026_04_17_200009_create_checkouts_table.php @@ -0,0 +1,54 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('cart_id')->constrained('carts')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('status')->default(CheckoutStatus::Started->value); + $table->string('payment_method')->nullable(); + $table->string('email')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->unsignedBigInteger('shipping_method_id')->nullable(); + $table->string('discount_code')->nullable(); + $table->text('tax_provider_snapshot_json')->nullable(); + $table->text('totals_json')->nullable(); + $table->timestamp('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'); + }); + + $statuses = collect(CheckoutStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $methods = collect(PaymentMethod::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + DB::statement("CREATE TRIGGER checkouts_status_check_insert BEFORE INSERT ON checkouts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER checkouts_status_check_update BEFORE UPDATE ON checkouts FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER checkouts_payment_check_insert BEFORE INSERT ON checkouts FOR EACH ROW WHEN NEW.payment_method IS NOT NULL AND NEW.payment_method NOT IN ({$methods}) BEGIN SELECT RAISE(ABORT, 'invalid payment_method'); END"); + DB::statement("CREATE TRIGGER checkouts_payment_check_update BEFORE UPDATE ON checkouts FOR EACH ROW WHEN NEW.payment_method IS NOT NULL AND NEW.payment_method NOT IN ({$methods}) BEGIN SELECT RAISE(ABORT, 'invalid payment_method'); END"); + } + + public function down(): void + { + foreach (['checkouts_status_check_insert', 'checkouts_status_check_update', 'checkouts_payment_check_insert', 'checkouts_payment_check_update'] as $trigger) { + DB::statement("DROP TRIGGER IF EXISTS {$trigger}"); + } + Schema::dropIfExists('checkouts'); + } +}; diff --git a/database/migrations/2026_04_17_201101_create_analytics_events_table.php b/database/migrations/2026_04_17_201101_create_analytics_events_table.php new file mode 100644 index 00000000..81abbca7 --- /dev/null +++ b/database/migrations/2026_04_17_201101_create_analytics_events_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('type'); + $table->string('session_id')->nullable(); + $table->unsignedBigInteger('customer_id')->nullable(); + $table->text('properties_json')->default('{}'); + $table->string('client_event_id')->nullable(); + $table->timestamp('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(['store_id', 'type', 'occurred_at'], 'idx_analytics_events_store_type_occurred'); + $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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_events'); + } +}; diff --git a/database/migrations/2026_04_17_201102_create_analytics_daily_table.php b/database/migrations/2026_04_17_201102_create_analytics_daily_table.php new file mode 100644 index 00000000..e2d0c78f --- /dev/null +++ b/database/migrations/2026_04_17_201102_create_analytics_daily_table.php @@ -0,0 +1,31 @@ +foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->string('date', 10); + $table->unsignedInteger('orders_count')->default(0); + $table->unsignedBigInteger('revenue_amount')->default(0); + $table->unsignedBigInteger('aov_amount')->default(0); + $table->unsignedInteger('visits_count')->default(0); + $table->unsignedInteger('add_to_cart_count')->default(0); + $table->unsignedInteger('checkout_started_count')->default(0); + $table->unsignedInteger('checkout_completed_count')->default(0); + + $table->primary(['store_id', 'date']); + $table->index(['store_id', 'date'], 'idx_analytics_daily_store_date'); + }); + } + + public function down(): void + { + Schema::dropIfExists('analytics_daily'); + } +}; diff --git a/database/migrations/2026_04_17_210001_create_orders_table.php b/database/migrations/2026_04_17_210001_create_orders_table.php new file mode 100644 index 00000000..12b9d429 --- /dev/null +++ b/database/migrations/2026_04_17_210001_create_orders_table.php @@ -0,0 +1,69 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('customer_id')->nullable()->constrained('customers')->nullOnDelete(); + $table->string('order_number'); + $table->string('payment_method'); + $table->string('status')->default(OrderStatus::Pending->value); + $table->string('financial_status')->default(FinancialStatus::Pending->value); + $table->string('fulfillment_status')->default(FulfillmentStatus::Unfulfilled->value); + $table->string('currency', 3)->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->string('email')->nullable(); + $table->text('billing_address_json')->nullable(); + $table->text('shipping_address_json')->nullable(); + $table->timestamp('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'); + }); + + $methods = collect(PaymentMethod::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $statuses = collect(OrderStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $financial = collect(FinancialStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $fulfillment = collect(FulfillmentStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + foreach (['insert', 'update'] as $action) { + $when = strtoupper($action); + DB::statement("CREATE TRIGGER orders_method_check_{$action} BEFORE {$when} ON orders FOR EACH ROW WHEN NEW.payment_method NOT IN ({$methods}) BEGIN SELECT RAISE(ABORT, 'invalid payment_method'); END"); + DB::statement("CREATE TRIGGER orders_status_check_{$action} BEFORE {$when} ON orders FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER orders_financial_check_{$action} BEFORE {$when} ON orders FOR EACH ROW WHEN NEW.financial_status NOT IN ({$financial}) BEGIN SELECT RAISE(ABORT, 'invalid financial_status'); END"); + DB::statement("CREATE TRIGGER orders_fulfillment_check_{$action} BEFORE {$when} ON orders FOR EACH ROW WHEN NEW.fulfillment_status NOT IN ({$fulfillment}) BEGIN SELECT RAISE(ABORT, 'invalid fulfillment_status'); END"); + } + } + + public function down(): void + { + foreach (['insert', 'update'] as $action) { + foreach (['method', 'status', 'financial', 'fulfillment'] as $prefix) { + DB::statement("DROP TRIGGER IF EXISTS orders_{$prefix}_check_{$action}"); + } + } + Schema::dropIfExists('orders'); + } +}; diff --git a/database/migrations/2026_04_17_210002_create_order_lines_table.php b/database/migrations/2026_04_17_210002_create_order_lines_table.php new file mode 100644 index 00000000..26086f57 --- /dev/null +++ b/database/migrations/2026_04_17_210002_create_order_lines_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('product_id')->nullable()->constrained('products')->nullOnDelete(); + $table->foreignId('variant_id')->nullable()->constrained('product_variants')->nullOnDelete(); + $table->string('title_snapshot'); + $table->string('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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('order_lines'); + } +}; diff --git a/database/migrations/2026_04_17_210003_create_payments_table.php b/database/migrations/2026_04_17_210003_create_payments_table.php new file mode 100644 index 00000000..1279d155 --- /dev/null +++ b/database/migrations/2026_04_17_210003_create_payments_table.php @@ -0,0 +1,52 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->string('provider')->default('mock'); + $table->string('method'); + $table->string('provider_payment_id')->nullable(); + $table->string('status')->default(PaymentStatus::Pending->value); + $table->integer('amount')->default(0); + $table->string('currency', 3)->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'); + }); + + $methods = collect(PaymentMethod::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + $statuses = collect(PaymentStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + foreach (['insert', 'update'] as $action) { + $when = strtoupper($action); + DB::statement("CREATE TRIGGER payments_method_check_{$action} BEFORE {$when} ON payments FOR EACH ROW WHEN NEW.method NOT IN ({$methods}) BEGIN SELECT RAISE(ABORT, 'invalid method'); END"); + DB::statement("CREATE TRIGGER payments_status_check_{$action} BEFORE {$when} ON payments FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER payments_provider_check_{$action} BEFORE {$when} ON payments FOR EACH ROW WHEN NEW.provider NOT IN ('mock') BEGIN SELECT RAISE(ABORT, 'invalid provider'); END"); + } + } + + public function down(): void + { + foreach (['insert', 'update'] as $action) { + foreach (['method', 'status', 'provider'] as $prefix) { + DB::statement("DROP TRIGGER IF EXISTS payments_{$prefix}_check_{$action}"); + } + } + Schema::dropIfExists('payments'); + } +}; diff --git a/database/migrations/2026_04_17_210004_create_refunds_table.php b/database/migrations/2026_04_17_210004_create_refunds_table.php new file mode 100644 index 00000000..407bf9e1 --- /dev/null +++ b/database/migrations/2026_04_17_210004_create_refunds_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->foreignId('payment_id')->constrained('payments')->cascadeOnDelete(); + $table->integer('amount')->default(0); + $table->string('reason')->nullable(); + $table->string('status')->default(RefundStatus::Pending->value); + $table->string('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'); + }); + + $statuses = collect(RefundStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + foreach (['insert', 'update'] as $action) { + $when = strtoupper($action); + DB::statement("CREATE TRIGGER refunds_status_check_{$action} BEFORE {$when} ON refunds FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + } + + public function down(): void + { + foreach (['insert', 'update'] as $action) { + DB::statement("DROP TRIGGER IF EXISTS refunds_status_check_{$action}"); + } + Schema::dropIfExists('refunds'); + } +}; diff --git a/database/migrations/2026_04_17_210005_create_fulfillments_table.php b/database/migrations/2026_04_17_210005_create_fulfillments_table.php new file mode 100644 index 00000000..12680a12 --- /dev/null +++ b/database/migrations/2026_04_17_210005_create_fulfillments_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('order_id')->constrained('orders')->cascadeOnDelete(); + $table->string('status')->default(FulfillmentShipmentStatus::Pending->value); + $table->string('tracking_company')->nullable(); + $table->string('tracking_number')->nullable(); + $table->string('tracking_url')->nullable(); + $table->timestamp('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'); + }); + + $statuses = collect(FulfillmentShipmentStatus::values())->map(fn (string $v): string => "'".$v."'")->implode(','); + + foreach (['insert', 'update'] as $action) { + $when = strtoupper($action); + DB::statement("CREATE TRIGGER fulfillments_status_check_{$action} BEFORE {$when} ON fulfillments FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + } + + public function down(): void + { + foreach (['insert', 'update'] as $action) { + DB::statement("DROP TRIGGER IF EXISTS fulfillments_status_check_{$action}"); + } + Schema::dropIfExists('fulfillments'); + } +}; diff --git a/database/migrations/2026_04_17_210006_create_fulfillment_lines_table.php b/database/migrations/2026_04_17_210006_create_fulfillment_lines_table.php new file mode 100644 index 00000000..1cf1072d --- /dev/null +++ b/database/migrations/2026_04_17_210006_create_fulfillment_lines_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('fulfillment_id')->constrained('fulfillments')->cascadeOnDelete(); + $table->foreignId('order_line_id')->constrained('order_lines')->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'); + }); + } + + public function down(): void + { + Schema::dropIfExists('fulfillment_lines'); + } +}; diff --git a/database/migrations/2026_04_17_220001_create_apps_table.php b/database/migrations/2026_04_17_220001_create_apps_table.php new file mode 100644 index 00000000..e6ecad1a --- /dev/null +++ b/database/migrations/2026_04_17_220001_create_apps_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('name'); + $table->string('handle')->unique('idx_apps_handle'); + $table->string('status')->default('active'); + $table->timestamp('created_at')->nullable(); + + $table->index('status', 'idx_apps_status'); + }); + + $statuses = "'active','disabled'"; + DB::statement("CREATE TRIGGER apps_status_check_insert BEFORE INSERT ON apps FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER apps_status_check_update BEFORE UPDATE ON apps FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS apps_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS apps_status_check_update'); + Schema::dropIfExists('apps'); + } +}; diff --git a/database/migrations/2026_04_17_220002_create_app_installations_table.php b/database/migrations/2026_04_17_220002_create_app_installations_table.php new file mode 100644 index 00000000..3165b487 --- /dev/null +++ b/database/migrations/2026_04_17_220002_create_app_installations_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_id')->constrained('apps')->cascadeOnDelete(); + $table->text('scopes_json')->default('[]'); + $table->string('status')->default('active'); + $table->timestamp('installed_at')->nullable(); + $table->timestamp('uninstalled_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'); + }); + + $statuses = "'active','suspended','uninstalled'"; + DB::statement("CREATE TRIGGER app_installations_status_check_insert BEFORE INSERT ON app_installations FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER app_installations_status_check_update BEFORE UPDATE ON app_installations FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS app_installations_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS app_installations_status_check_update'); + Schema::dropIfExists('app_installations'); + } +}; diff --git a/database/migrations/2026_04_17_220003_create_webhook_subscriptions_table.php b/database/migrations/2026_04_17_220003_create_webhook_subscriptions_table.php new file mode 100644 index 00000000..0d0efab8 --- /dev/null +++ b/database/migrations/2026_04_17_220003_create_webhook_subscriptions_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('store_id')->constrained('stores')->cascadeOnDelete(); + $table->foreignId('app_installation_id')->nullable()->constrained('app_installations')->cascadeOnDelete(); + $table->string('event_type'); + $table->string('target_url'); + $table->text('signing_secret_encrypted'); + $table->string('status')->default('active'); + $table->integer('consecutive_failures')->default(0); + $table->timestamp('created_at')->nullable(); + + $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'); + }); + + $statuses = "'active','paused','disabled'"; + DB::statement("CREATE TRIGGER webhook_subs_status_check_insert BEFORE INSERT ON webhook_subscriptions FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER webhook_subs_status_check_update BEFORE UPDATE ON webhook_subscriptions FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS webhook_subs_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS webhook_subs_status_check_update'); + Schema::dropIfExists('webhook_subscriptions'); + } +}; diff --git a/database/migrations/2026_04_17_220004_create_webhook_deliveries_table.php b/database/migrations/2026_04_17_220004_create_webhook_deliveries_table.php new file mode 100644 index 00000000..66f2e714 --- /dev/null +++ b/database/migrations/2026_04_17_220004_create_webhook_deliveries_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('subscription_id')->constrained('webhook_subscriptions')->cascadeOnDelete(); + $table->string('event_id'); + $table->integer('attempt_count')->default(1); + $table->string('status')->default('pending'); + $table->timestamp('last_attempt_at')->nullable(); + $table->timestamp('next_retry_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'); + }); + + $statuses = "'pending','success','failed'"; + DB::statement("CREATE TRIGGER webhook_deliveries_status_check_insert BEFORE INSERT ON webhook_deliveries FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + DB::statement("CREATE TRIGGER webhook_deliveries_status_check_update BEFORE UPDATE ON webhook_deliveries FOR EACH ROW WHEN NEW.status NOT IN ({$statuses}) BEGIN SELECT RAISE(ABORT, 'invalid status'); END"); + } + + public function down(): void + { + DB::statement('DROP TRIGGER IF EXISTS webhook_deliveries_status_check_insert'); + DB::statement('DROP TRIGGER IF EXISTS webhook_deliveries_status_check_update'); + Schema::dropIfExists('webhook_deliveries'); + } +}; diff --git a/database/migrations/2026_04_17_230001_create_personal_access_tokens_table.php b/database/migrations/2026_04_17_230001_create_personal_access_tokens_table.php new file mode 100644 index 00000000..40ff706e --- /dev/null +++ b/database/migrations/2026_04_17_230001_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/database/seeders/AdminUsersSeeder.php b/database/seeders/AdminUsersSeeder.php new file mode 100644 index 00000000..af29b9c2 --- /dev/null +++ b/database/seeders/AdminUsersSeeder.php @@ -0,0 +1,54 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $users = [ + ['email' => 'owner@shop.test', 'name' => 'Shop Owner', 'role' => StoreUserRole::Owner], + ['email' => 'admin@shop.test', 'name' => 'Shop Admin', 'role' => StoreUserRole::Admin], + ['email' => 'staff@shop.test', 'name' => 'Shop Staff', 'role' => StoreUserRole::Staff], + ['email' => 'support@shop.test', 'name' => 'Shop Support', 'role' => StoreUserRole::Support], + ]; + + foreach ($users as $entry) { + $user = User::query()->firstOrCreate( + ['email' => $entry['email']], + [ + 'name' => $entry['name'], + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ], + ); + + $hasMembership = DB::table('store_users') + ->where('store_id', $store->getKey()) + ->where('user_id', $user->getKey()) + ->exists(); + + if (! $hasMembership) { + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $entry['role']->value, + 'created_at' => now(), + ]); + } + } + } +} diff --git a/database/seeders/AnalyticsDemoSeeder.php b/database/seeders/AnalyticsDemoSeeder.php new file mode 100644 index 00000000..837ef10a --- /dev/null +++ b/database/seeders/AnalyticsDemoSeeder.php @@ -0,0 +1,119 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $days = 14; + + for ($offset = $days; $offset >= 0; $offset--) { + $date = Carbon::now()->subDays($offset)->startOfDay(); + + $visits = random_int(50, 200); + $productViews = random_int($visits, $visits * 3); + $addToCart = (int) floor($visits * 0.2); + $checkoutStarted = (int) floor($addToCart * 0.6); + $checkoutCompleted = (int) floor($checkoutStarted * 0.5); + + $session = 'seed-sess-'.$offset; + + for ($i = 0; $i < $visits; $i++) { + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::PageView->value, + 'session_id' => $session.'-'.$i, + 'customer_id' => null, + 'properties_json' => ['path' => '/'], + 'client_event_id' => null, + 'occurred_at' => $date->copy()->addMinutes(random_int(0, 1439)), + 'created_at' => $date, + ]); + } + + for ($i = 0; $i < $productViews; $i++) { + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::ProductView->value, + 'session_id' => $session.'-'.($i % $visits), + 'customer_id' => null, + 'properties_json' => ['product_id' => random_int(1, 18)], + 'client_event_id' => null, + 'occurred_at' => $date->copy()->addMinutes(random_int(0, 1439)), + 'created_at' => $date, + ]); + } + + for ($i = 0; $i < $addToCart; $i++) { + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::AddToCart->value, + 'session_id' => $session.'-'.($i % $visits), + 'customer_id' => null, + 'properties_json' => ['variant_id' => random_int(1, 50), 'quantity' => 1], + 'client_event_id' => (string) Str::uuid(), + 'occurred_at' => $date->copy()->addMinutes(random_int(0, 1439)), + 'created_at' => $date, + ]); + } + + for ($i = 0; $i < $checkoutStarted; $i++) { + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::CheckoutStarted->value, + 'session_id' => $session.'-'.($i % $visits), + 'customer_id' => null, + 'properties_json' => [], + 'client_event_id' => null, + 'occurred_at' => $date->copy()->addMinutes(random_int(0, 1439)), + 'created_at' => $date, + ]); + } + + for ($i = 0; $i < $checkoutCompleted; $i++) { + AnalyticsEvent::query()->create([ + 'store_id' => $store->getKey(), + 'type' => AnalyticsEventType::CheckoutCompleted->value, + 'session_id' => $session.'-'.($i % $visits), + 'customer_id' => null, + 'properties_json' => ['total_amount' => random_int(1000, 20_000)], + 'client_event_id' => null, + 'occurred_at' => $date->copy()->addMinutes(random_int(0, 1439)), + 'created_at' => $date, + ]); + } + + $ordersCount = $checkoutCompleted; + $revenue = $ordersCount * random_int(3_500, 9_000); + $aov = $ordersCount > 0 ? (int) floor($revenue / $ordersCount) : 0; + + AnalyticsDaily::query()->updateOrCreate( + ['store_id' => $store->getKey(), 'date' => $date->toDateString()], + [ + 'orders_count' => $ordersCount, + 'revenue_amount' => $revenue, + 'aov_amount' => $aov, + 'visits_count' => $visits, + 'add_to_cart_count' => $addToCart, + 'checkout_started_count' => $checkoutStarted, + 'checkout_completed_count' => $checkoutCompleted, + ], + ); + } + } +} diff --git a/database/seeders/CatalogSeeder.php b/database/seeders/CatalogSeeder.php new file mode 100644 index 00000000..4532aae1 --- /dev/null +++ b/database/seeders/CatalogSeeder.php @@ -0,0 +1,215 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $storeId = (int) $store->getKey(); + + $catalog = [ + ['title' => 'Classic Tee', 'type' => 'Apparel', 'options' => [['Size', ['S', 'M', 'L']], ['Color', ['Black', 'White']]], 'price' => 2500, 'tags' => ['apparel', 'tops', 'new-arrival']], + ['title' => 'Summer Dress', 'type' => 'Apparel', 'options' => [['Size', ['S', 'M', 'L', 'XL']]], 'price' => 5900, 'tags' => ['apparel', 'sale']], + ['title' => 'Denim Jacket', 'type' => 'Apparel', 'options' => [['Size', ['M', 'L']], ['Color', ['Blue', 'Black']]], 'price' => 8900, 'tags' => ['apparel', 'outerwear']], + ['title' => 'Running Sneakers', 'type' => 'Footwear', 'options' => [['Size', ['8', '9', '10', '11']]], 'price' => 11900, 'tags' => ['footwear', 'sport', 'new-arrival']], + ['title' => 'Leather Wallet', 'type' => 'Accessories', 'options' => [['Color', ['Brown', 'Black']]], 'price' => 4500, 'tags' => ['accessories']], + ['title' => 'Canvas Tote', 'type' => 'Accessories', 'options' => [['Color', ['Natural', 'Navy']]], 'price' => 2200, 'tags' => ['accessories', 'sale']], + ['title' => 'Wool Beanie', 'type' => 'Accessories', 'options' => [['Color', ['Grey', 'Black', 'Cream']]], 'price' => 1800, 'tags' => ['accessories']], + ['title' => 'Ceramic Mug', 'type' => 'Home', 'options' => [['Color', ['White', 'Terracotta']]], 'price' => 1500, 'tags' => ['home', 'clearance']], + ['title' => 'Scented Candle', 'type' => 'Home', 'options' => [['Scent', ['Vanilla', 'Pine', 'Citrus']]], 'price' => 2900, 'tags' => ['home']], + ['title' => 'Cotton Socks', 'type' => 'Apparel', 'options' => [['Size', ['S', 'M', 'L']], ['Color', ['White', 'Black']]], 'price' => 1200, 'tags' => ['apparel']], + ['title' => 'Rain Jacket', 'type' => 'Apparel', 'options' => [['Size', ['S', 'M', 'L']]], 'price' => 13900, 'tags' => ['apparel', 'outerwear']], + ['title' => 'Linen Shirt', 'type' => 'Apparel', 'options' => [['Size', ['M', 'L', 'XL']], ['Color', ['White', 'Sky']]], 'price' => 4900, 'tags' => ['apparel', 'new-arrival']], + ['title' => 'Leather Belt', 'type' => 'Accessories', 'options' => [['Size', ['S', 'M', 'L']]], 'price' => 3500, 'tags' => ['accessories']], + ['title' => 'Sunglasses', 'type' => 'Accessories', 'options' => [['Color', ['Black', 'Tortoise']]], 'price' => 5900, 'tags' => ['accessories', 'new-arrival']], + ['title' => 'Throw Blanket', 'type' => 'Home', 'options' => [['Color', ['Charcoal', 'Sand']]], 'price' => 6900, 'tags' => ['home']], + ['title' => 'Espresso Cup Set', 'type' => 'Home', 'options' => [], 'price' => 3200, 'tags' => ['home', 'clearance']], + ['title' => 'Hiking Boots', 'type' => 'Footwear', 'options' => [['Size', ['9', '10', '11', '12']]], 'price' => 15900, 'tags' => ['footwear', 'sport']], + ['title' => 'Yoga Mat', 'type' => 'Sport', 'options' => [['Color', ['Purple', 'Teal', 'Black']]], 'price' => 4200, 'tags' => ['sport', 'new-arrival']], + ]; + + $collections = [ + 'featured' => ['title' => 'Featured', 'description' => 'Featured picks from the team.', 'tag' => 'new-arrival', 'status' => CollectionStatus::Active], + 'sale' => ['title' => 'Sale', 'description' => 'Discounted items, limited quantities.', 'tag' => 'sale', 'status' => CollectionStatus::Active], + 'new-arrivals' => ['title' => 'New Arrivals', 'description' => 'Fresh stock, just in.', 'tag' => 'new-arrival', 'status' => CollectionStatus::Active], + 'clearance' => ['title' => 'Clearance', 'description' => 'Final markdowns.', 'tag' => 'clearance', 'status' => CollectionStatus::Active], + 'outerwear' => ['title' => 'Outerwear', 'description' => 'Jackets and cover-ups.', 'tag' => 'outerwear', 'status' => CollectionStatus::Active], + ]; + + $collectionModels = []; + + foreach ($collections as $handleSeed => $entry) { + $collectionModels[$handleSeed] = Collection::query()->create([ + 'store_id' => $storeId, + 'title' => $entry['title'], + 'handle' => HandleGenerator::unique(Collection::class, $storeId, $entry['title']), + 'type' => CollectionType::Manual->value, + 'status' => $entry['status']->value, + 'description_html' => '

'.$entry['description'].'

', + ]); + } + + $createdProducts = []; + + foreach ($catalog as $entry) { + $product = Product::query()->create([ + 'store_id' => $storeId, + 'title' => $entry['title'], + 'handle' => HandleGenerator::unique(Product::class, $storeId, $entry['title']), + 'status' => ProductStatus::Active->value, + 'description_html' => '

'.$entry['title'].' from Shop.

', + 'vendor' => 'Shop', + 'product_type' => $entry['type'], + 'tags' => $entry['tags'], + 'published_at' => now(), + ]); + + $valueGroups = []; + + foreach ($entry['options'] as $optionIndex => [$name, $values]) { + $option = ProductOption::query()->create([ + 'product_id' => $product->getKey(), + 'name' => $name, + 'position' => $optionIndex, + ]); + + $valueIds = []; + + foreach ($values as $valueIndex => $value) { + $pov = ProductOptionValue::query()->create([ + 'product_option_id' => $option->getKey(), + 'value' => $value, + 'position' => $valueIndex, + ]); + $valueIds[] = (int) $pov->getKey(); + } + + $valueGroups[] = $valueIds; + } + + $combos = $this->cartesianProduct($valueGroups); + $combos = array_slice($combos, 0, 4); + if ($combos === []) { + $combos = [[]]; + } + + $position = 0; + + foreach ($combos as $combo) { + $variant = ProductVariant::query()->create([ + 'product_id' => $product->getKey(), + 'sku' => strtoupper(Str::slug($entry['title'])).'-'.strtoupper(Str::random(4)), + 'price_amount' => $entry['price'], + 'currency' => 'USD', + 'requires_shipping' => 1, + 'is_default' => $position === 0 ? 1 : 0, + 'position' => $position, + 'status' => VariantStatus::Active->value, + ]); + + if (! empty($combo)) { + $variant->optionValues()->sync($combo); + } + + InventoryItem::query()->create([ + 'store_id' => $storeId, + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => random_int(10, 80), + 'quantity_reserved' => 0, + 'policy' => InventoryPolicy::Deny->value, + ]); + + $position++; + } + + ProductMedia::query()->create([ + 'product_id' => $product->getKey(), + 'type' => MediaType::Image->value, + 'storage_key' => 'media/placeholders/'.Str::slug($entry['title']).'.jpg', + 'alt_text' => $entry['title'].' product image', + 'width' => 1200, + 'height' => 1200, + 'mime_type' => 'image/jpeg', + 'byte_size' => 180_000, + 'position' => 0, + 'status' => MediaStatus::Ready->value, + 'created_at' => now(), + ]); + + $createdProducts[] = $product; + + foreach ($collectionModels as $handleSeed => $collection) { + $collectionEntry = $collections[$handleSeed]; + + if (in_array($collectionEntry['tag'], $entry['tags'], true)) { + $collection->products()->syncWithoutDetaching([ + $product->getKey() => ['position' => $collection->products()->count()], + ]); + } + } + } + + $featured = $collectionModels['featured']; + + foreach (array_slice($createdProducts, 0, 6) as $index => $product) { + if (! $featured->products()->where('products.id', $product->getKey())->exists()) { + $featured->products()->attach($product->getKey(), ['position' => $index]); + } + } + } + + /** + * @param array> $groups + * @return array> + */ + protected function cartesianProduct(array $groups): array + { + $groups = array_values(array_filter($groups, fn (array $g): bool => $g !== [])); + + if ($groups === []) { + return [[]]; + } + + $result = [[]]; + + foreach ($groups as $group) { + $next = []; + + foreach ($result as $partial) { + foreach ($group as $value) { + $next[] = array_merge($partial, [$value]); + } + } + + $result = $next; + } + + return $result; + } +} diff --git a/database/seeders/CommerceSeeder.php b/database/seeders/CommerceSeeder.php new file mode 100644 index 00000000..a59e7980 --- /dev/null +++ b/database/seeders/CommerceSeeder.php @@ -0,0 +1,152 @@ +get()->each(function (Store $store): void { + $zone = ShippingZone::query() + ->withoutGlobalScopes() + ->firstOrCreate( + ['store_id' => $store->getKey(), 'name' => 'Domestic'], + ['countries_json' => ['US'], 'regions_json' => []], + ); + + ShippingRate::query()->firstOrCreate( + ['zone_id' => $zone->getKey(), 'name' => 'Standard'], + [ + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 799], + 'is_active' => 1, + ], + ); + + ShippingRate::query()->firstOrCreate( + ['zone_id' => $zone->getKey(), 'name' => 'Express'], + [ + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 1499], + 'is_active' => 1, + ], + ); + + $international = ShippingZone::query() + ->withoutGlobalScopes() + ->firstOrCreate( + ['store_id' => $store->getKey(), 'name' => 'International'], + ['countries_json' => ['CA', 'MX', 'GB', 'DE', 'FR'], 'regions_json' => []], + ); + + ShippingRate::query()->firstOrCreate( + ['zone_id' => $international->getKey(), 'name' => 'International Standard'], + [ + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 2499], + 'is_active' => 1, + ], + ); + + TaxSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + [ + 'mode' => TaxMode::Manual->value, + 'provider' => TaxProviderType::None->value, + 'prices_include_tax' => 0, + 'config_json' => ['default_rate_bps' => 0], + ], + ); + + $discounts = [ + [ + 'code' => 'WELCOME10', + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 10, + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'status' => DiscountStatus::Active->value, + 'rules_json' => [], + ], + [ + 'code' => 'FREESHIP', + 'value_type' => DiscountValueType::FreeShipping->value, + 'value_amount' => 0, + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'status' => DiscountStatus::Active->value, + 'rules_json' => [], + ], + [ + 'code' => 'SALE20', + 'value_type' => DiscountValueType::Fixed->value, + 'value_amount' => 2000, + 'starts_at' => now()->subDay(), + 'ends_at' => null, + 'usage_limit' => null, + 'usage_count' => 0, + 'status' => DiscountStatus::Active->value, + 'rules_json' => ['minimum_subtotal' => 5000], + ], + [ + 'code' => 'SPRING23', + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 15, + 'starts_at' => now()->subDays(60), + 'ends_at' => now()->subDays(30), + 'usage_limit' => null, + 'usage_count' => 42, + 'status' => DiscountStatus::Expired->value, + 'rules_json' => [], + ], + [ + 'code' => 'LIMITED50', + 'value_type' => DiscountValueType::Percent->value, + 'value_amount' => 50, + 'starts_at' => now()->subDays(7), + 'ends_at' => null, + 'usage_limit' => 10, + 'usage_count' => 10, + 'status' => DiscountStatus::Active->value, + 'rules_json' => [], + ], + ]; + + foreach ($discounts as $entry) { + Discount::query() + ->withoutGlobalScopes() + ->updateOrCreate( + ['store_id' => $store->getKey(), 'code' => $entry['code']], + [ + 'type' => DiscountType::Code->value, + 'value_type' => $entry['value_type'], + 'value_amount' => $entry['value_amount'], + 'starts_at' => $entry['starts_at'], + 'ends_at' => $entry['ends_at'], + 'usage_limit' => $entry['usage_limit'], + 'usage_count' => $entry['usage_count'], + 'rules_json' => $entry['rules_json'], + 'status' => $entry['status'], + ], + ); + } + }); + } +} diff --git a/database/seeders/CustomersSeeder.php b/database/seeders/CustomersSeeder.php new file mode 100644 index 00000000..26b3f307 --- /dev/null +++ b/database/seeders/CustomersSeeder.php @@ -0,0 +1,60 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $customers = [ + ['email' => 'amelia.young@example.com', 'name' => 'Amelia Young', 'city' => 'Austin', 'state' => 'TX'], + ['email' => 'ben.carter@example.com', 'name' => 'Ben Carter', 'city' => 'Denver', 'state' => 'CO'], + ['email' => 'cora.nguyen@example.com', 'name' => 'Cora Nguyen', 'city' => 'Seattle', 'state' => 'WA'], + ['email' => 'dan.olsen@example.com', 'name' => 'Dan Olsen', 'city' => 'Chicago', 'state' => 'IL'], + ['email' => 'eva.park@example.com', 'name' => 'Eva Park', 'city' => 'New York', 'state' => 'NY'], + ['email' => 'finn.lee@example.com', 'name' => 'Finn Lee', 'city' => 'Miami', 'state' => 'FL'], + ['email' => 'gia.sato@example.com', 'name' => 'Gia Sato', 'city' => 'Portland', 'state' => 'OR'], + ['email' => 'hank.miller@example.com', 'name' => 'Hank Miller', 'city' => 'Boston', 'state' => 'MA'], + ]; + + foreach ($customers as $entry) { + $customer = Customer::query()->firstOrCreate( + ['store_id' => $store->getKey(), 'email' => $entry['email']], + [ + 'name' => $entry['name'], + 'password_hash' => Hash::make('password'), + 'marketing_opt_in' => (bool) random_int(0, 1), + 'email_verified_at' => now(), + ], + ); + + CustomerAddress::query()->firstOrCreate( + ['customer_id' => $customer->getKey(), 'label' => 'Home'], + [ + 'address_json' => [ + 'first_name' => explode(' ', $entry['name'])[0], + 'last_name' => explode(' ', $entry['name'])[1] ?? '', + 'address1' => random_int(100, 999).' Main St', + 'city' => $entry['city'], + 'province_code' => $entry['state'], + 'country_code' => 'US', + 'postal_code' => (string) random_int(10_000, 99_999), + ], + 'is_default' => 1, + ], + ); + } + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d01a0ef2..3d6f2e54 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -2,22 +2,22 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; class DatabaseSeeder extends Seeder { - /** - * Seed the application's database. - */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->call(DefaultStoreSeeder::class); + $this->call(AdminUsersSeeder::class); + $this->call(ThemeSeeder::class); + $this->call(PageSeeder::class); + $this->call(NavigationSeeder::class); + $this->call(CatalogSeeder::class); + $this->call(CommerceSeeder::class); + $this->call(CustomersSeeder::class); + $this->call(OrdersSeeder::class); + $this->call(AnalyticsDemoSeeder::class); + $this->call(WebhooksDemoSeeder::class); } } diff --git a/database/seeders/DefaultStoreSeeder.php b/database/seeders/DefaultStoreSeeder.php new file mode 100644 index 00000000..53371fbb --- /dev/null +++ b/database/seeders/DefaultStoreSeeder.php @@ -0,0 +1,85 @@ +firstOrCreate( + ['billing_email' => 'billing@shop.test'], + ['name' => 'Shop Holdings'], + ); + + $store = Store::query()->firstOrCreate( + ['handle' => 'shop'], + [ + 'organization_id' => $organization->getKey(), + 'name' => 'Shop', + 'status' => StoreStatus::Active->value, + 'default_currency' => 'USD', + 'default_locale' => 'en', + 'timezone' => 'UTC', + ], + ); + + StoreDomain::query()->firstOrCreate( + ['hostname' => 'shop.test'], + [ + 'store_id' => $store->getKey(), + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + 'tls_mode' => 'managed', + 'created_at' => now(), + ], + ); + + StoreDomain::query()->firstOrCreate( + ['hostname' => 'admin.shop.test'], + [ + 'store_id' => $store->getKey(), + 'type' => StoreDomainType::Admin->value, + 'is_primary' => 0, + 'tls_mode' => 'managed', + 'created_at' => now(), + ], + ); + + if (app()->environment('testing')) { + StoreDomain::query()->firstOrCreate( + ['hostname' => '127.0.0.1'], + [ + 'store_id' => $store->getKey(), + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 0, + 'tls_mode' => 'managed', + 'created_at' => now(), + ], + ); + + StoreDomain::query()->firstOrCreate( + ['hostname' => 'localhost'], + [ + 'store_id' => $store->getKey(), + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 0, + 'tls_mode' => 'managed', + 'created_at' => now(), + ], + ); + } + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => '{}'], + ); + } +} diff --git a/database/seeders/NavigationSeeder.php b/database/seeders/NavigationSeeder.php new file mode 100644 index 00000000..3455dc8e --- /dev/null +++ b/database/seeders/NavigationSeeder.php @@ -0,0 +1,80 @@ +each(function (Store $store): void { + $menu = NavigationMenu::query()->firstOrCreate( + ['store_id' => $store->getKey(), 'handle' => 'main-menu'], + ['title' => 'Main menu'], + ); + + $about = Page::query() + ->where('store_id', $store->getKey()) + ->where('handle', 'about') + ->first(); + + $contact = Page::query() + ->where('store_id', $store->getKey()) + ->where('handle', 'contact') + ->first(); + + $items = [ + [ + 'type' => NavigationItemType::Link->value, + 'label' => 'Home', + 'url' => '/', + 'resource_id' => null, + 'position' => 0, + ], + [ + 'type' => NavigationItemType::Link->value, + 'label' => 'Shop', + 'url' => '/collections/all', + 'resource_id' => null, + 'position' => 1, + ], + ]; + + if ($about !== null) { + $items[] = [ + 'type' => NavigationItemType::Page->value, + 'label' => 'About', + 'url' => null, + 'resource_id' => $about->getKey(), + 'position' => 2, + ]; + } + + if ($contact !== null) { + $items[] = [ + 'type' => NavigationItemType::Page->value, + 'label' => 'Contact', + 'url' => null, + 'resource_id' => $contact->getKey(), + 'position' => 3, + ]; + } + + foreach ($items as $data) { + NavigationItem::query()->firstOrCreate( + [ + 'menu_id' => $menu->getKey(), + 'label' => $data['label'], + ], + $data, + ); + } + }); + } +} diff --git a/database/seeders/OrdersSeeder.php b/database/seeders/OrdersSeeder.php new file mode 100644 index 00000000..66958bad --- /dev/null +++ b/database/seeders/OrdersSeeder.php @@ -0,0 +1,227 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $customers = Customer::query()->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->get(); + + if ($customers->isEmpty()) { + return; + } + + $variants = ProductVariant::query() + ->whereIn('product_id', DB::table('products')->where('store_id', $store->getKey())->pluck('id')) + ->get(); + + if ($variants->isEmpty()) { + return; + } + + $scenarios = [ + ['status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => null], + ['status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Partial, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => FulfillmentShipmentStatus::Pending, 'partial' => true], + ['status' => OrderStatus::Fulfilled, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => FulfillmentShipmentStatus::Delivered], + ['status' => OrderStatus::Fulfilled, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, 'method' => PaymentMethod::Paypal, 'refund' => null, 'shipment' => FulfillmentShipmentStatus::Shipped], + ['status' => OrderStatus::Pending, 'financial' => FinancialStatus::Pending, 'fulfillment' => FulfillmentStatus::Unfulfilled, 'method' => PaymentMethod::BankTransfer, 'refund' => null, 'shipment' => null], + ['status' => OrderStatus::Cancelled, 'financial' => FinancialStatus::Voided, 'fulfillment' => FulfillmentStatus::Unfulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => null], + ['status' => OrderStatus::Refunded, 'financial' => FinancialStatus::Refunded, 'fulfillment' => FulfillmentStatus::Fulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => 'full', 'shipment' => FulfillmentShipmentStatus::Delivered], + ['status' => OrderStatus::Paid, 'financial' => FinancialStatus::PartiallyRefunded, 'fulfillment' => FulfillmentStatus::Fulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => 'partial', 'shipment' => FulfillmentShipmentStatus::Delivered], + ['status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Unfulfilled, 'method' => PaymentMethod::Paypal, 'refund' => null, 'shipment' => null], + ['status' => OrderStatus::Fulfilled, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Fulfilled, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => FulfillmentShipmentStatus::Shipped], + ['status' => OrderStatus::Paid, 'financial' => FinancialStatus::Paid, 'fulfillment' => FulfillmentStatus::Partial, 'method' => PaymentMethod::CreditCard, 'refund' => null, 'shipment' => FulfillmentShipmentStatus::Shipped, 'partial' => true], + ['status' => OrderStatus::Pending, 'financial' => FinancialStatus::Pending, 'fulfillment' => FulfillmentStatus::Unfulfilled, 'method' => PaymentMethod::BankTransfer, 'refund' => null, 'shipment' => null], + ]; + + $orderNumber = 1001; + + foreach ($scenarios as $index => $scenario) { + $customer = $customers[$index % $customers->count()]; + $variantA = $variants->random(); + $variantB = $variants->random(); + $placedAt = Carbon::now()->subDays(random_int(0, 12))->subHours(random_int(0, 20)); + + $lineQty1 = random_int(1, 3); + $lineQty2 = random_int(1, 2); + + $subtotal = $variantA->price_amount * $lineQty1 + $variantB->price_amount * $lineQty2; + $shipping = 799; + $tax = (int) floor($subtotal * 0.08); + $total = $subtotal + $shipping + $tax; + + $address = [ + 'first_name' => explode(' ', $customer->name)[0], + 'last_name' => explode(' ', $customer->name)[1] ?? '', + 'address1' => random_int(100, 999).' Main St', + 'city' => 'Austin', + 'province_code' => 'TX', + 'country_code' => 'US', + 'postal_code' => (string) random_int(10_000, 99_999), + ]; + + $order = Order::query()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $customer->getKey(), + 'order_number' => '#'.($orderNumber + $index), + 'payment_method' => $scenario['method']->value, + 'status' => $scenario['status']->value, + 'financial_status' => $scenario['financial']->value, + 'fulfillment_status' => $scenario['fulfillment']->value, + 'currency' => 'USD', + 'subtotal_amount' => $subtotal, + 'discount_amount' => 0, + 'shipping_amount' => $shipping, + 'tax_amount' => $tax, + 'total_amount' => $total, + 'email' => $customer->email, + 'billing_address_json' => $address, + 'shipping_address_json' => $address, + 'placed_at' => $placedAt, + ]); + + $line1 = OrderLine::query()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $variantA->product_id, + 'variant_id' => $variantA->getKey(), + 'title_snapshot' => $variantA->product->title ?? 'Product', + 'sku_snapshot' => $variantA->sku, + 'quantity' => $lineQty1, + 'unit_price_amount' => $variantA->price_amount, + 'total_amount' => $variantA->price_amount * $lineQty1, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + + $line2 = OrderLine::query()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $variantB->product_id, + 'variant_id' => $variantB->getKey(), + 'title_snapshot' => $variantB->product->title ?? 'Product', + 'sku_snapshot' => $variantB->sku, + 'quantity' => $lineQty2, + 'unit_price_amount' => $variantB->price_amount, + 'total_amount' => $variantB->price_amount * $lineQty2, + 'tax_lines_json' => [], + 'discount_allocations_json' => [], + ]); + + if ($scenario['financial'] === FinancialStatus::Paid || $scenario['financial'] === FinancialStatus::PartiallyRefunded || $scenario['financial'] === FinancialStatus::Refunded) { + Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $scenario['method']->value, + 'provider_payment_id' => 'mock_'.uniqid(), + 'status' => PaymentStatus::Captured->value, + 'amount' => $total, + 'currency' => 'USD', + 'raw_json_encrypted' => ['result' => 'captured'], + 'created_at' => $placedAt, + ]); + } elseif ($scenario['financial'] === FinancialStatus::Voided) { + Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $scenario['method']->value, + 'provider_payment_id' => 'mock_'.uniqid(), + 'status' => PaymentStatus::Failed->value, + 'amount' => $total, + 'currency' => 'USD', + 'raw_json_encrypted' => ['result' => 'voided'], + 'created_at' => $placedAt, + ]); + } elseif ($scenario['financial'] === FinancialStatus::Pending) { + Payment::query()->create([ + 'order_id' => $order->getKey(), + 'provider' => 'mock', + 'method' => $scenario['method']->value, + 'provider_payment_id' => 'mock_'.uniqid(), + 'status' => PaymentStatus::Pending->value, + 'amount' => $total, + 'currency' => 'USD', + 'raw_json_encrypted' => ['result' => 'pending'], + 'created_at' => $placedAt, + ]); + } + + if ($scenario['shipment'] !== null) { + $fulfillment = Fulfillment::query()->create([ + 'order_id' => $order->getKey(), + 'status' => $scenario['shipment']->value, + 'tracking_company' => 'UPS', + 'tracking_number' => 'TRK'.random_int(1_000_000, 9_999_999), + 'tracking_url' => 'https://example.com/track/TRK'.random_int(100_000, 999_999), + 'shipped_at' => $scenario['shipment'] === FulfillmentShipmentStatus::Pending ? null : $placedAt->copy()->addHours(2), + 'created_at' => $placedAt, + ]); + + $partial = ! empty($scenario['partial']); + + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $line1->getKey(), + 'quantity' => $line1->quantity, + ]); + + if (! $partial) { + FulfillmentLine::query()->create([ + 'fulfillment_id' => $fulfillment->getKey(), + 'order_line_id' => $line2->getKey(), + 'quantity' => $line2->quantity, + ]); + } + } + + if ($scenario['refund'] === 'full') { + Refund::query()->create([ + 'order_id' => $order->getKey(), + 'payment_id' => $order->payments()->first()?->getKey(), + 'amount' => $total, + 'reason' => 'Customer requested return.', + 'status' => RefundStatus::Processed->value, + 'provider_refund_id' => 'refund_'.uniqid(), + 'created_at' => $placedAt->copy()->addDay(), + ]); + } elseif ($scenario['refund'] === 'partial') { + Refund::query()->create([ + 'order_id' => $order->getKey(), + 'payment_id' => $order->payments()->first()?->getKey(), + 'amount' => (int) floor($total / 3), + 'reason' => 'One item returned.', + 'status' => RefundStatus::Processed->value, + 'provider_refund_id' => 'refund_'.uniqid(), + 'created_at' => $placedAt->copy()->addDay(), + ]); + } + } + } +} diff --git a/database/seeders/PageSeeder.php b/database/seeders/PageSeeder.php new file mode 100644 index 00000000..f4f9b8e7 --- /dev/null +++ b/database/seeders/PageSeeder.php @@ -0,0 +1,36 @@ +each(function (Store $store): void { + Page::query()->firstOrCreate( + ['store_id' => $store->getKey(), 'handle' => 'about'], + [ + 'title' => 'About Us', + 'body_html' => '

Welcome to '.e($store->name).'. We craft a curated selection of goods with care and attention to quality.

', + 'status' => PageStatus::Published->value, + 'published_at' => now(), + ], + ); + + Page::query()->firstOrCreate( + ['store_id' => $store->getKey(), 'handle' => 'contact'], + [ + 'title' => 'Contact', + 'body_html' => '

Questions or feedback? Email hello@shop.test.

', + 'status' => PageStatus::Published->value, + 'published_at' => now(), + ], + ); + }); + } +} diff --git a/database/seeders/ThemeSeeder.php b/database/seeders/ThemeSeeder.php new file mode 100644 index 00000000..86e7d632 --- /dev/null +++ b/database/seeders/ThemeSeeder.php @@ -0,0 +1,41 @@ +each(function (Store $store): void { + $theme = Theme::query()->firstOrCreate( + ['store_id' => $store->getKey(), 'name' => 'Default'], + [ + 'version' => '1.0.0', + 'status' => ThemeStatus::Published->value, + 'published_at' => now(), + ], + ); + + ThemeSettings::query()->updateOrCreate( + ['theme_id' => $theme->getKey()], + ['settings_json' => json_encode([ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Free shipping on orders over $50', + 'link' => null, + ], + 'colors' => [ + 'primary' => '#111827', + 'accent' => '#6366f1', + ], + ], JSON_THROW_ON_ERROR)], + ); + }); + } +} diff --git a/database/seeders/WebhooksDemoSeeder.php b/database/seeders/WebhooksDemoSeeder.php new file mode 100644 index 00000000..c62847f7 --- /dev/null +++ b/database/seeders/WebhooksDemoSeeder.php @@ -0,0 +1,43 @@ +where('handle', 'shop')->first(); + + if ($store === null) { + return; + } + + $subscriptions = [ + ['event' => WebhookTopic::OrderPaid->value, 'url' => 'https://example.com/webhooks/order-paid'], + ['event' => WebhookTopic::OrderCancelled->value, 'url' => 'https://example.com/webhooks/order-cancelled'], + ]; + + foreach ($subscriptions as $entry) { + WebhookSubscription::query() + ->withoutGlobalScopes() + ->firstOrCreate( + [ + 'store_id' => $store->getKey(), + 'event_type' => $entry['event'], + 'target_url' => $entry['url'], + ], + [ + 'signing_secret_encrypted' => 'whsec_demo_'.bin2hex(random_bytes(16)), + 'status' => 'active', + 'consecutive_failures' => 0, + 'created_at' => now(), + ], + ); + } + } +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 00000000..9ccd8787 --- /dev/null +++ b/opencode.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + }, + "playwright": { + "type": "local", + "command": [ + "npx", + "@playwright/mcp@latest" + ], + "enabled": true + } + } +} diff --git a/phpunit.xml b/phpunit.xml index d7032415..602e5cf4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -11,6 +11,9 @@ tests/Feature + + tests/Browser + diff --git a/resources/views/components/layouts/admin.blade.php b/resources/views/components/layouts/admin.blade.php new file mode 100644 index 00000000..5e817f4a --- /dev/null +++ b/resources/views/components/layouts/admin.blade.php @@ -0,0 +1,187 @@ +@props(['title' => null]) + +@php + $currentStore = app()->bound('current_store') ? app('current_store') : null; + $user = auth()->user(); + $accessibleStores = $user + ? $user->stores()->get() + : collect(); +@endphp + + + + + + + + {{ $title ? $title.' | Admin' : 'Admin' }}{{ $currentStore ? ' - '.$currentStore->name : '' }} + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + @livewireStyles + + +
+ + +
+
+
+ @if ($currentStore) + + + {{ $currentStore->name }} + + + @foreach ($accessibleStores as $store) + + {{ $store->name }} + @if ($store->getKey() === $currentStore->getKey()) + current + @endif + + @endforeach + + + @endif +
+ +
+ @if ($user) + + + + Profile + +
+ @csrf + + Log out + +
+
+
+ @endif +
+
+ +
+ {{ $slot }} +
+ + @if (session('status')) + + @endif + @if (session('error')) + + @endif +
+
+ +
+ +
+ + @fluxScripts + @livewireScripts + + diff --git a/resources/views/components/layouts/storefront.blade.php b/resources/views/components/layouts/storefront.blade.php new file mode 100644 index 00000000..52650ffb --- /dev/null +++ b/resources/views/components/layouts/storefront.blade.php @@ -0,0 +1,119 @@ +@props(['title' => null]) + +@php + $store = isset($currentStore) ? $currentStore : null; + $storeName = $store ? $store->name : config('app.name'); + + $announcement = ['enabled' => true, 'text' => 'Free shipping on orders over $50', 'link' => null]; + if ($store) { + $settings = $store->settings; + $json = is_array($settings?->settings_json ?? null) ? $settings->settings_json : []; + if (isset($json['announcement']) && is_array($json['announcement'])) { + $announcement = array_merge($announcement, $json['announcement']); + } + } + + $currentPath = '/'.ltrim(request()->path(), '/'); +@endphp + + + + + + + {{ $title ? $title.' | '.$storeName : $storeName }} + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + @livewireStyles + + + + Skip to main content + + + @if (! empty($announcement['enabled']) && ! empty($announcement['text'])) +
+ @if (! empty($announcement['link'])) + + {{ $announcement['text'] }} + + @else + {{ $announcement['text'] }} + @endif +
+ @endif + +
+
+ + {{ $storeName }} + + + + +
+ + @auth('customer') + + Account + + @else + + Sign in + + @endauth +
+
+
+ + @if (session('status') || session('success') || session('error')) +
+ @if (session('status') || session('success')) +
+ {{ session('status') ?? session('success') }} +
+ @endif + @if (session('error')) +
+ {{ session('error') }} +
+ @endif +
+ @endif + +
+ {{ $slot }} +
+ +
+
+
+ © {{ now()->year }} {{ $storeName }}. All rights reserved. +
+ +
+
+ + @fluxScripts + @livewireScripts + + diff --git a/resources/views/errors/403.blade.php b/resources/views/errors/403.blade.php new file mode 100644 index 00000000..4bd4d49d --- /dev/null +++ b/resources/views/errors/403.blade.php @@ -0,0 +1,5 @@ +@include('errors.layout', [ + 'status' => 403, + 'headline' => 'Forbidden', + 'message' => $exception?->getMessage() ?: 'You do not have permission to access this page.', +]) diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php new file mode 100644 index 00000000..fe97ff04 --- /dev/null +++ b/resources/views/errors/404.blade.php @@ -0,0 +1,5 @@ +@include('errors.layout', [ + 'status' => 404, + 'headline' => 'Page not found', + 'message' => $exception?->getMessage() ?: 'The page you are looking for does not exist or has been moved.', +]) diff --git a/resources/views/errors/500.blade.php b/resources/views/errors/500.blade.php new file mode 100644 index 00000000..336b4666 --- /dev/null +++ b/resources/views/errors/500.blade.php @@ -0,0 +1,5 @@ +@include('errors.layout', [ + 'status' => 500, + 'headline' => 'Something went wrong', + 'message' => 'An unexpected error occurred. Please try again shortly.', +]) diff --git a/resources/views/errors/503.blade.php b/resources/views/errors/503.blade.php new file mode 100644 index 00000000..a82b83ea --- /dev/null +++ b/resources/views/errors/503.blade.php @@ -0,0 +1,5 @@ +@include('errors.layout', [ + 'status' => 503, + 'headline' => 'Temporarily unavailable', + 'message' => $exception?->getMessage() ?: 'This store is currently unavailable. Please check back soon.', +]) diff --git a/resources/views/errors/layout.blade.php b/resources/views/errors/layout.blade.php new file mode 100644 index 00000000..810b731a --- /dev/null +++ b/resources/views/errors/layout.blade.php @@ -0,0 +1,44 @@ +@php + /** @var int $status */ + /** @var string $headline */ + /** @var string $message */ + $useStorefront = app()->bound('current_store'); +@endphp + +@if ($useStorefront) + +
+

+ Error {{ $status }} +

+

{{ $headline }}

+

{{ $message }}

+ + Return home + +
+
+@else + + + + + + {{ $headline }} + @vite(['resources/css/app.css']) + + +
+

+ Error {{ $status }} +

+

{{ $headline }}

+

{{ $message }}

+ + Return home + +
+ + +@endif 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..a30529fb --- /dev/null +++ b/resources/views/livewire/admin/auth/login.blade.php @@ -0,0 +1,10 @@ +
+ Admin sign in + +
+ + + + Log in + +
diff --git a/resources/views/livewire/admin/auth/logout.blade.php b/resources/views/livewire/admin/auth/logout.blade.php new file mode 100644 index 00000000..9646e41c --- /dev/null +++ b/resources/views/livewire/admin/auth/logout.blade.php @@ -0,0 +1,5 @@ +
+
+ Sign out +
+
diff --git a/resources/views/livewire/admin/collections/edit.blade.php b/resources/views/livewire/admin/collections/edit.blade.php new file mode 100644 index 00000000..9040e79e --- /dev/null +++ b/resources/views/livewire/admin/collections/edit.blade.php @@ -0,0 +1,40 @@ +
+
+ {{ $collectionId ? 'Edit collection' : 'Add collection' }} + Back +
+ +
+
+
+ + Title + + + + + Handle + + + + + Description + + +
+
+ +
+
+ + Status + + Active + Archived + + +
+ Save collection +
+
+
diff --git a/resources/views/livewire/admin/collections/index.blade.php b/resources/views/livewire/admin/collections/index.blade.php new file mode 100644 index 00000000..b3b310d6 --- /dev/null +++ b/resources/views/livewire/admin/collections/index.blade.php @@ -0,0 +1,39 @@ +
+
+ Collections + Add collection +
+ + + +
+ + + + + + + + + + + @forelse ($collections as $collection) + + + + + + + @empty + + @endforelse + +
TitleProductsStatusUpdated
+ {{ $collection->title }} + {{ $collection->products_count }} + {{ $collection->status->value }} + {{ $collection->updated_at?->diffForHumans() }}
No collections yet.
+
+ +
{{ $collections->links() }}
+
diff --git a/resources/views/livewire/admin/customers/index.blade.php b/resources/views/livewire/admin/customers/index.blade.php new file mode 100644 index 00000000..bfa76478 --- /dev/null +++ b/resources/views/livewire/admin/customers/index.blade.php @@ -0,0 +1,36 @@ +
+ Customers + + + +
+ + + + + + + + + + + + @forelse ($customers as $customer) + + + + + + + + @empty + + @endforelse + +
NameEmailOrdersSpentJoined
+ {{ $customer->name ?? '-' }} + {{ $customer->email }}{{ $customer->orders_count }}{{ number_format(((int) ($customer->total_spent ?? 0)) / 100, 2) }}{{ $customer->created_at?->format('Y-m-d') }}
No customers yet.
+
+ +
{{ $customers->links() }}
+
diff --git a/resources/views/livewire/admin/customers/show.blade.php b/resources/views/livewire/admin/customers/show.blade.php new file mode 100644 index 00000000..9a2d3aa6 --- /dev/null +++ b/resources/views/livewire/admin/customers/show.blade.php @@ -0,0 +1,73 @@ +
+
+ {{ $customer->name ?? $customer->email }} + Back +
+ +
+
+
+ Customer info + +
+
Name
{{ $customer->name ?? '-' }}
+
Email
{{ $customer->email }}
+
Created
{{ $customer->created_at?->format('Y-m-d') }}
+
Marketing
{{ $customer->marketing_opt_in ? 'Opted in' : 'No' }}
+
+
+ +
+
+ Order history +
+ + + + + + + + + + + @forelse ($customer->orders as $order) + + + + + + + @empty + + @endforelse + +
Order #DateStatusTotal
+ {{ $order->order_number }} + {{ optional($order->placed_at)->format('Y-m-d') }}{{ $order->financial_status->value }}{{ number_format($order->total_amount / 100, 2) }}
No orders yet.
+
+
+ +
+
+ Addresses + + @if ($customer->addresses->isEmpty()) +

No addresses on file.

+ @else +
    + @foreach ($customer->addresses as $address) +
  • +
    {{ $address->label ?? 'Address' }}
    + @php $json = $address->address_json ?? []; @endphp +
    {{ $json['line1'] ?? '' }}
    +
    {{ $json['city'] ?? '' }} {{ $json['region'] ?? '' }} {{ $json['postal_code'] ?? '' }}
    +
    {{ $json['country'] ?? '' }}
    +
  • + @endforeach +
+ @endif +
+
+
+
diff --git a/resources/views/livewire/admin/dashboard.blade.php b/resources/views/livewire/admin/dashboard.blade.php new file mode 100644 index 00000000..8834c3c8 --- /dev/null +++ b/resources/views/livewire/admin/dashboard.blade.php @@ -0,0 +1,70 @@ +
+
+ Dashboard +
+ +
+
+ Revenue today + {{ number_format($revenueToday / 100, 2) }} +
+
+ Orders today + {{ $ordersCount }} +
+
+ Average order value + {{ number_format($aov / 100, 2) }} +
+
+ Visits today + {{ $visitsToday }} +
+
+ +
+ Conversion funnel (today) +
+
Visits
{{ $visitsToday }}
+
Add to cart
{{ $addToCartToday }}
+
Checkout started
{{ $checkoutStartedToday }}
+
Checkout completed
{{ $checkoutCompletedToday }}
+
+
+ +
+
+ Recent orders +
+
+ + + + + + + + + + + + @forelse ($recentOrders as $order) + + + + + + + + @empty + + @endforelse + +
OrderCustomerStatusTotalPlaced
+ {{ $order->order_number }} + {{ $order->email ?? 'Guest' }} + {{ str_replace('_', ' ', $order->financial_status->value) }} + {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}{{ optional($order->placed_at)->format('Y-m-d H:i') }}
No orders yet.
+
+
+
diff --git a/resources/views/livewire/admin/discounts/edit.blade.php b/resources/views/livewire/admin/discounts/edit.blade.php new file mode 100644 index 00000000..df47dff5 --- /dev/null +++ b/resources/views/livewire/admin/discounts/edit.blade.php @@ -0,0 +1,75 @@ +
+
+ {{ $discountId ? 'Edit discount' : 'Create discount' }} + Back +
+ +
+
+ + Type + + Discount code + Automatic + + + @if ($type === 'code') + + Code + + + + @endif +
+ +
+ + Value type + + Percentage + Fixed amount + Free shipping + + + @if ($valueType !== 'free_shipping') + + Amount + + Percent in whole numbers, or cents for fixed amount. + + @endif +
+ +
+ + Starts at + + + + + Ends at + + +
+ +
+ + Usage limit + + + + Status + + Draft + Active + Expired + Disabled + + +
+ +
+ Save discount +
+
+
diff --git a/resources/views/livewire/admin/discounts/index.blade.php b/resources/views/livewire/admin/discounts/index.blade.php new file mode 100644 index 00000000..2bff0959 --- /dev/null +++ b/resources/views/livewire/admin/discounts/index.blade.php @@ -0,0 +1,53 @@ +
+
+ Discounts + Create discount +
+ + + +
+ + + + + + + + + + + + + @forelse ($discounts as $discount) + + + + + + + + + @empty + + @endforelse + +
CodeTypeValueUsageStatusDates
+ {{ $discount->code ?? 'Automatic' }} + {{ $discount->type->value }} + @if ($discount->value_type->value === 'percent') + {{ $discount->value_amount }}% + @elseif ($discount->value_type->value === 'fixed') + {{ number_format($discount->value_amount / 100, 2) }} + @else + Free shipping + @endif + {{ $discount->usage_count }} / {{ $discount->usage_limit ?? 'unlimited' }}{{ $discount->status->value }} + {{ optional($discount->starts_at)->format('M j') }} + - + {{ optional($discount->ends_at)->format('M j') ?? '-' }} +
No discounts yet.
+
+ +
{{ $discounts->links() }}
+
diff --git a/resources/views/livewire/admin/orders/index.blade.php b/resources/views/livewire/admin/orders/index.blade.php new file mode 100644 index 00000000..decd5819 --- /dev/null +++ b/resources/views/livewire/admin/orders/index.blade.php @@ -0,0 +1,61 @@ +
+ Orders + +
+ + + All + Pending + Paid + Partially refunded + Refunded + Voided + +
+ +
+ + + + + + + + + + + + + @forelse ($orders as $order) + @php + $financialColor = match ($order->financial_status->value) { + 'paid' => 'green', + 'refunded' => 'yellow', + 'voided' => 'red', + default => 'zinc', + }; + $fulfillmentColor = match ($order->fulfillment_status->value) { + 'fulfilled' => 'green', + 'partial' => 'yellow', + default => 'zinc', + }; + @endphp + + + + + + + + + @empty + + @endforelse + +
Order #CustomerDateFinancialFulfillmentTotal
+ {{ $order->order_number }} + {{ $order->email ?? 'Guest' }}{{ optional($order->placed_at)->format('M j, Y g:i A') }}{{ str_replace('_', ' ', $order->financial_status->value) }}{{ $order->fulfillment_status->value }}{{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}
No orders yet.
+
+ +
{{ $orders->links() }}
+
diff --git a/resources/views/livewire/admin/orders/show.blade.php b/resources/views/livewire/admin/orders/show.blade.php new file mode 100644 index 00000000..d70dc8e1 --- /dev/null +++ b/resources/views/livewire/admin/orders/show.blade.php @@ -0,0 +1,220 @@ +
+
+
+ Order {{ $order->order_number }} +

{{ optional($order->placed_at)->format('M j, Y g:i A') }}

+
+ Back to orders +
+ +
+ Financial: {{ str_replace('_', ' ', $order->financial_status->value) }} + Fulfillment: {{ $order->fulfillment_status->value }} + Payment: {{ $order->payment_method->value }} +
+ + @error('payment') {{ $message }} @enderror + @error('fulfillment') {{ $message }} @enderror + @error('refund') {{ $message }} @enderror + @error('cancel') {{ $message }} @enderror + +
+ @if ($order->payment_method->value === 'bank_transfer' && $order->financial_status->value === 'pending') + Confirm payment + @endif + @if ($canFulfill && ! in_array($order->financial_status->value, ['pending','voided']) && $order->fulfillment_status !== $fulfilled) + Create fulfillment + @endif + @if ($canRefund && in_array($order->financial_status->value, ['paid','partially_refunded'])) + Refund + @endif + @if ($canCancel && $order->status->value !== 'cancelled' && $order->fulfillment_status !== $fulfilled) + Cancel order + @endif +
+ +
+
+
+
+ Order lines +
+ + + + + + + + + + + @foreach ($order->lines as $line) + + + + + + + @endforeach + +
ItemQtyUnitTotal
+
{{ $line->title_snapshot }}
+ @if ($line->sku_snapshot) +
SKU: {{ $line->sku_snapshot }}
+ @endif +
{{ $line->quantity }}{{ number_format($line->unit_price_amount / 100, 2) }}{{ number_format($line->total_amount / 100, 2) }}
+
+
Subtotal{{ number_format($order->subtotal_amount / 100, 2) }}
+ @if ($order->discount_amount) +
Discount-{{ number_format($order->discount_amount / 100, 2) }}
+ @endif +
Shipping{{ number_format($order->shipping_amount / 100, 2) }}
+
Tax{{ number_format($order->tax_amount / 100, 2) }}
+
Total{{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}
+
+
+ +
+
+ Payments +
+ @if ($order->payments->isEmpty()) +

No payments recorded.

+ @else +
    + @foreach ($order->payments as $payment) +
  • +
    +
    {{ $payment->method }} - {{ $payment->status->value }}
    +
    {{ $payment->provider_payment_id }}
    +
    +
    {{ number_format($payment->amount / 100, 2) }} {{ $payment->currency }}
    +
  • + @endforeach +
+ @endif +
+ +
+
+ Fulfillments +
+ @if ($order->fulfillments->isEmpty()) +

No fulfillments yet.

+ @else +
    + @foreach ($order->fulfillments as $fulfillment) +
  • +
    + {{ $fulfillment->status->value }} + @if ($fulfillment->tracking_number) + {{ $fulfillment->tracking_company }} {{ $fulfillment->tracking_number }} + @endif +
    +
    + @if ($fulfillment->status === $shipmentPending) + Mark shipped + @endif + @if ($fulfillment->status === $shipmentShipped) + Mark delivered + @endif +
    +
  • + @endforeach +
+ @endif +
+
+ +
+
+ Customer + + @if ($order->customer) +
{{ $order->customer->name ?? $order->customer->email }}
+ @else +
Guest
+ @endif +
{{ $order->email }}
+
+ + @if ($order->shipping_address_json) +
+ Shipping address + +
+
{{ $order->shipping_address_json['line1'] ?? '' }}
+
{{ $order->shipping_address_json['city'] ?? '' }} {{ $order->shipping_address_json['region'] ?? '' }} {{ $order->shipping_address_json['postal_code'] ?? '' }}
+
{{ $order->shipping_address_json['country'] ?? '' }}
+
+
+ @endif + + @if ($order->billing_address_json) +
+ Billing address + +
+
{{ $order->billing_address_json['line1'] ?? '' }}
+
{{ $order->billing_address_json['city'] ?? '' }} {{ $order->billing_address_json['region'] ?? '' }} {{ $order->billing_address_json['postal_code'] ?? '' }}
+
{{ $order->billing_address_json['country'] ?? '' }}
+
+
+ @endif +
+
+ + +
+ Create fulfillment +
+ @foreach ($order->lines as $line) +
+
+
{{ $line->title_snapshot }}
+
Unfulfilled: {{ $line->unfulfilledQuantity() }}
+
+ +
+ @endforeach +
+ + + Tracking company + + + + Tracking number + + + + Tracking URL + + +
+ Cancel + Create fulfillment +
+
+
+ + +
+ Refund order + + Amount (cents) + + Maximum refundable: {{ number_format($order->remainingRefundable() / 100, 2) }} {{ $order->currency }} + + + Reason + + +
+ Cancel + Create refund +
+
+
+
diff --git a/resources/views/livewire/admin/pages/edit.blade.php b/resources/views/livewire/admin/pages/edit.blade.php new file mode 100644 index 00000000..8d1ec5fe --- /dev/null +++ b/resources/views/livewire/admin/pages/edit.blade.php @@ -0,0 +1,40 @@ +
+
+ {{ $pageId ? 'Edit page' : 'Add page' }} + Back +
+ +
+
+
+ + Title + + + + + Handle + + + + Body (HTML) + + +
+
+ +
+
+ + Status + + Draft + Published + Archived + + +
+ Save page +
+
+
diff --git a/resources/views/livewire/admin/pages/index.blade.php b/resources/views/livewire/admin/pages/index.blade.php new file mode 100644 index 00000000..908d6e9e --- /dev/null +++ b/resources/views/livewire/admin/pages/index.blade.php @@ -0,0 +1,35 @@ +
+
+ Pages + Add page +
+ +
+ + + + + + + + + + + @forelse ($pages as $page) + + + + + + + @empty + + @endforelse + +
TitleHandleStatusUpdated
+ {{ $page->title }} + {{ $page->handle }}{{ $page->status->value }}{{ $page->updated_at?->diffForHumans() }}
No pages yet.
+
+ +
{{ $pages->links() }}
+
diff --git a/resources/views/livewire/admin/products/create.blade.php b/resources/views/livewire/admin/products/create.blade.php new file mode 100644 index 00000000..68ea3a3d --- /dev/null +++ b/resources/views/livewire/admin/products/create.blade.php @@ -0,0 +1,71 @@ +
+
+ Add product + Cancel +
+ +
+
+
+ + Title + + + + + Handle + + + + + Description + + + +
+ +
+ Default variant +
+ + Price (cents) + + + + + On hand + + + +
+
+
+ +
+
+ + Status + + Draft + Active + Archived + + + + Vendor + + + + Product type + + + + Tags + + Separate tags with commas. + +
+ Save product +
+
+
diff --git a/resources/views/livewire/admin/products/edit.blade.php b/resources/views/livewire/admin/products/edit.blade.php new file mode 100644 index 00000000..2171385f --- /dev/null +++ b/resources/views/livewire/admin/products/edit.blade.php @@ -0,0 +1,94 @@ +
+
+ {{ $product->title }} + Back +
+ +
+
+
+ + Title + + + + + Handle + + + + + Description + + +
+ +
+ Variants +
+ + + + + + + + + + + + @foreach ($variants as $index => $variant) + + + + + + + + @endforeach + +
SKUPriceCompare atWeight (g)On hand
+
+
+ +
+ Media +
Media uploader is available in a later iteration.
+ @if ($product->media->isNotEmpty()) +
    + @foreach ($product->media as $asset) +
  • {{ $asset->alt_text ?? 'Asset '.$asset->id }}
  • + @endforeach +
+ @endif +
+
+ +
+
+ + Status + + Draft + Active + Archived + + + + + Vendor + + + + Product type + + + + Tags + + +
+ Save changes +
+
+
diff --git a/resources/views/livewire/admin/products/index.blade.php b/resources/views/livewire/admin/products/index.blade.php new file mode 100644 index 00000000..454c2624 --- /dev/null +++ b/resources/views/livewire/admin/products/index.blade.php @@ -0,0 +1,59 @@ +
+
+ Products + Add product +
+ +
+ + + @foreach ($statuses as $status) + {{ ucfirst($status) }} + @endforeach + +
+ +
+ + + + + + + + + + + + @forelse ($products as $product) + @php + $inventory = $product->variants->sum(fn ($variant) => optional($variant->inventoryItem)->quantity_on_hand ?? 0); + $statusColor = match ($product->status->value) { + 'active' => 'green', + 'draft' => 'zinc', + 'archived' => 'red', + default => 'zinc', + }; + @endphp + + + + + + + + @empty + + + + @endforelse + +
TitleStatusInventoryVendorCreated
+ {{ $product->title }} + + {{ ucfirst($product->status->value) }} + {{ $inventory }}{{ $product->vendor ?? '-' }}{{ $product->created_at?->format('Y-m-d') }}
No products match your filters.
+
+ +
{{ $products->links() }}
+
diff --git a/resources/views/livewire/admin/settings/general.blade.php b/resources/views/livewire/admin/settings/general.blade.php new file mode 100644 index 00000000..6618364f --- /dev/null +++ b/resources/views/livewire/admin/settings/general.blade.php @@ -0,0 +1,54 @@ +
+
+ Settings +
+ + + +
+ Store details + + Store name + + + + + Store handle + + The store handle cannot be changed after creation. + + + + + Defaults + + Default currency + + USD + EUR + GBP + + + + Default locale + + English + German + French + + + + Timezone + + + +
+ Save +
+ +
diff --git a/resources/views/livewire/admin/settings/shipping.blade.php b/resources/views/livewire/admin/settings/shipping.blade.php new file mode 100644 index 00000000..77a87bf5 --- /dev/null +++ b/resources/views/livewire/admin/settings/shipping.blade.php @@ -0,0 +1,57 @@ +
+
+ Shipping +
+ + + +
+ + Zone name + + + + Add zone +
+ +
+ @forelse ($zones as $zone) +
+
+ {{ $zone->name }} + Delete zone +
+

Countries: {{ implode(', ', $zone->countries_json ?? []) ?: 'None' }}

+ + + + + + + + + + + @foreach ($zone->rates as $rate) + + + + + + + @endforeach + +
RateTypeActive
{{ $rate->name }}{{ $rate->type->value }}{{ $rate->is_active ? 'Yes' : 'No' }} + Remove +
+
+ @empty +
No shipping zones yet.
+ @endforelse +
+
diff --git a/resources/views/livewire/admin/settings/staff.blade.php b/resources/views/livewire/admin/settings/staff.blade.php new file mode 100644 index 00000000..fc80c69e --- /dev/null +++ b/resources/views/livewire/admin/settings/staff.blade.php @@ -0,0 +1,59 @@ +
+
+ Staff + Invite staff +
+ + + +
+ + + + + + + + + + @forelse ($members as $member) + + + + + + @empty + + @endforelse + +
NameEmailRole
{{ $member->name }}{{ $member->email }}{{ $member->pivot->role }}
No staff assigned yet.
+
+ + +
+ Invite staff + + Email + + + + + Role + + @foreach ($roles as $role) + {{ ucfirst($role) }} + @endforeach + + +
+ Cancel + Invite +
+
+
+
diff --git a/resources/views/livewire/admin/settings/taxes.blade.php b/resources/views/livewire/admin/settings/taxes.blade.php new file mode 100644 index 00000000..c15910de --- /dev/null +++ b/resources/views/livewire/admin/settings/taxes.blade.php @@ -0,0 +1,34 @@ +
+ Taxes + + + +
+ + Tax mode + + @foreach ($modes as $m) + {{ ucfirst($m) }} + @endforeach + + + + Provider + + @foreach ($providers as $p) + {{ str_replace('_', ' ', $p) }} + @endforeach + + + + +
+ Save +
+ +
diff --git a/resources/views/livewire/admin/settings/webhooks/deliveries.blade.php b/resources/views/livewire/admin/settings/webhooks/deliveries.blade.php new file mode 100644 index 00000000..c53f8bee --- /dev/null +++ b/resources/views/livewire/admin/settings/webhooks/deliveries.blade.php @@ -0,0 +1,36 @@ +
+
+

Deliveries

+ Back +
+
+ {{ $subscription->event_type }} → {{ $subscription->target_url }} +
+ +
+ + + + + + + + + + + + @forelse ($deliveries as $delivery) + + + + + + + + @empty + + @endforelse + +
Event IDStatusAttemptsHTTPLast attempt
{{ $delivery->event_id }}{{ $delivery->status }}{{ $delivery->attempt_count }}{{ $delivery->response_code ?? '-' }}{{ optional($delivery->last_attempt_at)->format('Y-m-d H:i:s') }}
No deliveries yet.
+
+
diff --git a/resources/views/livewire/admin/settings/webhooks/edit.blade.php b/resources/views/livewire/admin/settings/webhooks/edit.blade.php new file mode 100644 index 00000000..ff418c67 --- /dev/null +++ b/resources/views/livewire/admin/settings/webhooks/edit.blade.php @@ -0,0 +1,43 @@ +
+

+ {{ $subscriptionId ? 'Edit subscription' : 'New webhook subscription' }} +

+ +
+
+ + + @error('event_type')
{{ $message }}
@enderror +
+ +
+ + + @error('target_url')
{{ $message }}
@enderror +
+ +
+ + + @error('signing_secret')
{{ $message }}
@enderror +
+ +
+ + +
+ +
+ + Cancel +
+
+
diff --git a/resources/views/livewire/admin/settings/webhooks/index.blade.php b/resources/views/livewire/admin/settings/webhooks/index.blade.php new file mode 100644 index 00000000..f7e405f4 --- /dev/null +++ b/resources/views/livewire/admin/settings/webhooks/index.blade.php @@ -0,0 +1,36 @@ +
+
+

Webhooks

+ New subscription +
+ +
+ + + + + + + + + + + + @forelse ($subscriptions as $subscription) + + + + + + + + @empty + + @endforelse + +
EventTarget URLStatusFailures
{{ $subscription->event_type }}{{ $subscription->target_url }}{{ $subscription->status }}{{ $subscription->consecutive_failures }} + Edit + Deliveries +
No webhook subscriptions yet.
+
+
diff --git a/resources/views/livewire/admin/themes/index.blade.php b/resources/views/livewire/admin/themes/index.blade.php new file mode 100644 index 00000000..2c3b071b --- /dev/null +++ b/resources/views/livewire/admin/themes/index.blade.php @@ -0,0 +1,29 @@ +
+ Themes + +
+ @forelse ($themes as $theme) +
+
+
+
+
+
{{ $theme->name }}
+
v{{ $theme->version }}
+
+ + {{ $theme->status->value }} + +
+
+
+ @if ($theme->status->value !== 'published') + Publish + @endif +
+
+ @empty +
No themes found.
+ @endforelse +
+
diff --git a/resources/views/livewire/storefront/account/addresses.blade.php b/resources/views/livewire/storefront/account/addresses.blade.php new file mode 100644 index 00000000..99f4268c --- /dev/null +++ b/resources/views/livewire/storefront/account/addresses.blade.php @@ -0,0 +1,61 @@ +
+
+

Addresses

+
+ +
+

Saved addresses

+ @if ($addresses->isEmpty()) +

No addresses yet.

+ @else +
    + @foreach ($addresses as $address) + @php $data = $address->address_json ?? []; @endphp +
  • +
    +
    {{ $address->label ?: 'Address' }}
    + @if ($address->is_default) + Default + @endif +
    +

    + {{ ($data['first_name'] ?? '').' '.($data['last_name'] ?? '') }}
    + {{ $data['address1'] ?? '' }}
    + {{ $data['city'] ?? '' }} {{ $data['postal_code'] ?? '' }}
    + {{ $data['country_code'] ?? '' }} +

    +
    + + +
    +
  • + @endforeach +
+ @endif +
+ +
+

+ {{ $editingId ? 'Edit address' : 'Add a new address' }} +

+
+ + + + + + + + +
+ +
+
+ {{ $editingId ? 'Save changes' : 'Save address' }} + @if ($editingId) + Cancel + @endif +
+ +
+
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..3f82e6de --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/forgot-password.blade.php @@ -0,0 +1,18 @@ +
+ Forgot your password? + + @if ($status) +
+ {{ $status }} +
+ @endif + +
+ + Email password reset link + + + +
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..f020c7d8 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/login.blade.php @@ -0,0 +1,10 @@ +
+ Sign in + +
+ + + + Log in + +
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..83cea46e --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/register.blade.php @@ -0,0 +1,12 @@ +
+ Create an account + +
+ + + + + + Create account + +
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..4d6f42b3 --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/reset-password.blade.php @@ -0,0 +1,10 @@ +
+ Reset your password + +
+ + + + Reset password + +
diff --git a/resources/views/livewire/storefront/account/auth/set-password.blade.php b/resources/views/livewire/storefront/account/auth/set-password.blade.php new file mode 100644 index 00000000..ae7888da --- /dev/null +++ b/resources/views/livewire/storefront/account/auth/set-password.blade.php @@ -0,0 +1,13 @@ +
+ Set your password +

+ Create a password to finish setting up your account. +

+ +
+ + + + Set password and sign in + +
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..c15fe337 --- /dev/null +++ b/resources/views/livewire/storefront/account/dashboard.blade.php @@ -0,0 +1,55 @@ +
+
+

Hello, {{ $customer->name ?? $customer->email }}

+

{{ $customer->email }}

+
+ +
+ +

Orders

+

Review your order history.

+
+ +

Addresses

+

Manage your shipping addresses.

+
+ +

Profile

+

Update your contact information.

+
+
+ +
+
+

Recent orders

+ View all +
+ + @if ($recentOrders->isEmpty()) +

You do not have any orders yet.

+ @else +
    + @foreach ($recentOrders as $order) +
  • +
    + + Order {{ $order->order_number }} + +

    + {{ optional($order->placed_at)->format('M j, Y') }} +

    +
    +
    + {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }} +
    +
  • + @endforeach +
+ @endif +
+ +
+ @csrf + +
+
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..b0dfeb57 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/index.blade.php @@ -0,0 +1,27 @@ +
+
+

Your orders

+
+ + @if ($orders->isEmpty()) +

You do not have any orders yet.

+ @else +
    + @foreach ($orders as $order) +
  • +
    + + Order {{ $order->order_number }} + +

    + {{ optional($order->placed_at)->format('M j, Y') }} · {{ $order->status->value }} +

    +
    +
    + {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }} +
    +
  • + @endforeach +
+ @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..d4ab7599 --- /dev/null +++ b/resources/views/livewire/storefront/account/orders/show.blade.php @@ -0,0 +1,53 @@ +
+
+

Order {{ $order->order_number }}

+

+ Placed on {{ optional($order->placed_at)->format('F j, Y') }} · {{ $order->status->value }} +

+
+ +
+

Items

+
    + @foreach ($order->lines as $line) +
  • +
    +

    {{ $line->title_snapshot }}

    +

    Qty {{ $line->quantity }}

    +
    +
    + {{ number_format($line->total_amount / 100, 2) }} {{ $order->currency }} +
    +
  • + @endforeach +
+
+ +
+
+

Totals

+
+
Subtotal
{{ number_format($order->subtotal_amount / 100, 2) }} {{ $order->currency }}
+
Shipping
{{ number_format($order->shipping_amount / 100, 2) }} {{ $order->currency }}
+
Tax
{{ number_format($order->tax_amount / 100, 2) }} {{ $order->currency }}
+ @if ($order->discount_amount > 0) +
Discount
-{{ number_format($order->discount_amount / 100, 2) }} {{ $order->currency }}
+ @endif +
Total
{{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }}
+
+
+ +
+

Shipping address

+ @php $shipping = $order->shipping_address_json ?? []; @endphp +
+ {{ ($shipping['first_name'] ?? '').' '.($shipping['last_name'] ?? '') }}
+ {{ $shipping['address1'] ?? '' }}
+ {{ $shipping['city'] ?? '' }} {{ $shipping['postal_code'] ?? '' }}
+ {{ $shipping['country_code'] ?? '' }} +
+
+
+ + Back to orders +
diff --git a/resources/views/livewire/storefront/account/profile.blade.php b/resources/views/livewire/storefront/account/profile.blade.php new file mode 100644 index 00000000..0d33e104 --- /dev/null +++ b/resources/views/livewire/storefront/account/profile.blade.php @@ -0,0 +1,35 @@ +
+
+

Profile

+
+ + @if ($status) +
+ {{ $status }} +
+ @endif + +
+

Your information

+
+ + + +
+ Save profile +
+ +
+ +
+

Change password

+
+ + + +
+ Update password +
+ +
+
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..82cbb1d3 --- /dev/null +++ b/resources/views/livewire/storefront/cart/drawer.blade.php @@ -0,0 +1,85 @@ +
+ @php + $count = $cart ? (int) $cart->totalQuantity() : 0; + @endphp + + + + @if ($open) + + + + @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..4a12126e --- /dev/null +++ b/resources/views/livewire/storefront/cart/show.blade.php @@ -0,0 +1,48 @@ +
+

Your cart

+ + @if ($cart === null || $lines->isEmpty()) +
+

Your cart is empty.

+ Continue shopping +
+ @else +
+ @foreach ($lines as $line) +
+
+
{{ $line->variant?->product?->title ?? 'Variant' }}
+
SKU: {{ $line->variant?->sku ?? 'N/A' }}
+ @error('line_'.$line->id) +
{{ $message }}
+ @enderror +
+
+ +
+ {{ number_format($line->line_total_amount / 100, 2) }} {{ $cart->currency }} +
+ +
+
+ @endforeach +
+ +
+
+ Subtotal: {{ number_format($cart->subtotal() / 100, 2) }} {{ $cart->currency }} +
+ + 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..b3803a4d --- /dev/null +++ b/resources/views/livewire/storefront/checkout/show.blade.php @@ -0,0 +1,131 @@ +
+

Checkout

+ +
+
+
+

Contact and shipping address

+
+
+ + + @error('email')
{{ $message }}
@enderror +
+
+ + + @error('first_name')
{{ $message }}
@enderror +
+
+ + + @error('last_name')
{{ $message }}
@enderror +
+
+ + + @error('address1')
{{ $message }}
@enderror +
+
+ + + @error('city')
{{ $message }}
@enderror +
+
+ + +
+
+ + + @error('country_code')
{{ $message }}
@enderror +
+
+ + + @error('postal_code')
{{ $message }}
@enderror +
+
+ +
+
+
+ + @if ($rates->isNotEmpty()) +
+

Shipping

+
+ @foreach ($rates as $rate) + + @endforeach + @error('shipping_rate_id')
{{ $message }}
@enderror +
+
+ @endif + +
+

Payment

+
+ @foreach ($paymentMethods as $method) + + @endforeach + @if ($payment_method === 'credit_card') +
+ + +
+ @endif + @error('payment_method')
{{ $message }}
@enderror + @if ($payment_error !== '') +
{{ $payment_error }}
+ @endif + +
+
+
+ + +
+
diff --git a/resources/views/livewire/storefront/checkout/success.blade.php b/resources/views/livewire/storefront/checkout/success.blade.php new file mode 100644 index 00000000..8afffb5e --- /dev/null +++ b/resources/views/livewire/storefront/checkout/success.blade.php @@ -0,0 +1,18 @@ +
+

Order confirmed

+ @if ($order) +

+ Order number: {{ $order->order_number }} +

+

+ Total: {{ number_format($order->total_amount / 100, 2) }} {{ $order->currency }} +

+ @else +

+ Thanks for your purchase. A confirmation email is on its way. +

+ @endif + + Continue shopping + +
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..819c62c2 --- /dev/null +++ b/resources/views/livewire/storefront/collections/show.blade.php @@ -0,0 +1,48 @@ +
+ @if ($collection) +
+

{{ $collection->title }}

+ @if ($collection->description_html) +
+ {!! $collection->description_html !!} +
+ @endif +
+ + @if ($products->isEmpty()) +
+ No products in this collection. +
+ @else + + @endif + @else +
+

Collection not found

+

+ We could not find a collection with the handle "{{ $handle }}". +

+ + Back to home + +
+ @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..32e48a9a --- /dev/null +++ b/resources/views/livewire/storefront/home.blade.php @@ -0,0 +1,52 @@ +
+
+
+

+ Welcome to {{ $currentStore->name }} +

+

+ Discover a curated selection of goods, shipped fast and backed by care. +

+ +
+
+ +
+
+

Featured

+ + View all + +
+ @if ($featuredProducts->isEmpty()) +
+ No products yet. +
+ @else + + @endif +
+
diff --git a/resources/views/livewire/storefront/navigation.blade.php b/resources/views/livewire/storefront/navigation.blade.php new file mode 100644 index 00000000..b7f4294d --- /dev/null +++ b/resources/views/livewire/storefront/navigation.blade.php @@ -0,0 +1,14 @@ +
+ @if ($menu) + + @endif +
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..a55f8aea --- /dev/null +++ b/resources/views/livewire/storefront/pages/show.blade.php @@ -0,0 +1,9 @@ +
+
+

{{ $page->title }}

+
+ +
+ {!! $page->body_html !!} +
+
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..107da673 --- /dev/null +++ b/resources/views/livewire/storefront/products/show.blade.php @@ -0,0 +1,63 @@ +
+ @if ($product) + @php($selectedVariant = $product->variants->firstWhere('id', $selected_variant_id) ?? $product->variants->first()) +
+
+ {{ $product->vendor ?? 'Shop' }} +
+
+
+

{{ $product->vendor }}

+

{{ $product->title }}

+ @if ($selectedVariant) +

+ {{ $selectedVariant->currency }} {{ number_format($selectedVariant->price_amount / 100, 2) }} +

+ @endif +
+ + @if ($product->description_html) +
+ {!! $product->description_html !!} +
+ @endif + + @if ($product->variants->count() > 1) +
+ + +
+ @endif + +
+ + @if ($add_to_cart_error !== '') + + @endif +
+
+
+ @else +
+

Product not found

+

+ We could not find a product with the handle "{{ $handle }}". +

+ + Back to home + +
+ @endif +
diff --git a/resources/views/livewire/storefront/search/index.blade.php b/resources/views/livewire/storefront/search/index.blade.php new file mode 100644 index 00000000..2a46ce0a --- /dev/null +++ b/resources/views/livewire/storefront/search/index.blade.php @@ -0,0 +1,53 @@ +
+
+

Search

+
+ + +
+
+ + @if (trim($query) !== '') +
+

+ {{ $results->count() }} {{ Str::plural('result', $results->count()) }} for "{{ $query }}" +

+ + @if ($results->isEmpty()) +
+ No products matched your search. +
+ @else +
    + @foreach ($results as $product) + @php + $variant = $product->variants->firstWhere('status', \App\Enums\VariantStatus::Active) ?? $product->variants->first(); + @endphp +
  • + + {{ $product->title }} + + @if ($variant) + + {{ $variant->currency }} {{ number_format($variant->price_amount / 100, 2) }} + + @endif + @if ($product->vendor) + + {{ $product->vendor }} + + @endif +
  • + @endforeach +
+ @endif +
+ @endif +
diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 00000000..9944ee14 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,62 @@ +get('/user', fn ($request) => $request->user()); + +Route::prefix('storefront/v1') + ->middleware('store.resolve:storefront') + ->group(function (): void { + Route::post('carts', [CartController::class, 'store']); + Route::get('carts/{cart}', [CartController::class, 'show']); + Route::post('carts/{cart}/lines', [CartController::class, 'addLine']); + Route::put('carts/{cart}/lines/{line}', [CartController::class, 'updateLine']); + Route::delete('carts/{cart}/lines/{line}', [CartController::class, 'removeLine']); + + Route::post('checkouts', [CheckoutController::class, 'store']); + Route::get('checkouts/{checkout}', [CheckoutController::class, 'show']); + Route::put('checkouts/{checkout}/address', [CheckoutController::class, 'setAddress']); + Route::put('checkouts/{checkout}/shipping-method', [CheckoutController::class, 'setShippingMethod']); + Route::post('checkouts/{checkout}/apply-discount', [CheckoutController::class, 'applyDiscount']); + Route::delete('checkouts/{checkout}/discount', [CheckoutController::class, 'removeDiscount']); + Route::post('checkouts/{checkout}/pay', [CheckoutController::class, 'pay']); + + Route::get('orders/{orderNumber}', [StorefrontOrderController::class, 'show']) + ->where('orderNumber', '[%23#\d]+'); + + Route::get('search', SearchController::class); + Route::post('analytics/events', [AnalyticsEventsController::class, 'store']); + }); + +Route::prefix('admin/v1') + ->middleware('auth:sanctum') + ->group(function (): void { + Route::get('stores/{storeId}/products', [AdminProductController::class, 'index']); + Route::post('stores/{storeId}/products', [AdminProductController::class, 'store']); + Route::get('stores/{storeId}/products/{productId}', [AdminProductController::class, 'show']); + Route::put('stores/{storeId}/products/{productId}', [AdminProductController::class, 'update']); + Route::delete('stores/{storeId}/products/{productId}', [AdminProductController::class, 'destroy']); + + Route::get('stores/{storeId}/collections', [AdminCollectionController::class, 'index']); + Route::get('stores/{storeId}/collections/{collectionId}', [AdminCollectionController::class, 'show']); + + Route::get('stores/{storeId}/orders', [AdminOrderController::class, 'index']); + Route::get('stores/{storeId}/orders/{orderId}', [AdminOrderController::class, 'show']); + + Route::get('stores/{storeId}/customers', [AdminCustomerController::class, 'index']); + Route::get('stores/{storeId}/customers/{customerId}', [AdminCustomerController::class, 'show']); + + Route::get('stores/{storeId}/webhook-subscriptions', [AdminWebhookController::class, 'index']); + Route::post('stores/{storeId}/webhook-subscriptions', [AdminWebhookController::class, 'store']); + Route::delete('stores/{storeId}/webhook-subscriptions/{subscriptionId}', [AdminWebhookController::class, 'destroy']); + }); diff --git a/routes/console.php b/routes/console.php index 3c9adf1a..f85bae93 100644 --- a/routes/console.php +++ b/routes/console.php @@ -2,7 +2,10 @@ use Illuminate\Foundation\Inspiring; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\Schedule; Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); + +Schedule::command('analytics:rollup')->dailyAt('01:00'); diff --git a/routes/web.php b/routes/web.php index f755f111..88f1cea6 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,133 @@ name('home'); +Route::view('welcome', 'welcome')->name('welcome'); Route::view('dashboard', 'dashboard') ->middleware(['auth', 'verified']) ->name('dashboard'); require __DIR__.'/settings.php'; + +Route::prefix('admin')->name('admin.')->group(function (): void { + Route::middleware('guest')->group(function (): void { + Route::livewire('login', AdminLogin::class)->name('login'); + }); + + Route::middleware(['auth', 'verified'])->group(function (): void { + Route::get('switch-store/{store}', StoreSwitcherController::class)->name('store.switch'); + }); + + Route::middleware(['auth', 'verified', 'store.resolve:admin'])->group(function (): void { + Route::get('/', AdminDashboard::class)->name('dashboard'); + + Route::get('products', AdminProductsIndex::class)->name('products.index'); + Route::get('products/create', AdminProductsCreate::class)->name('products.create'); + Route::get('products/{product}/edit', AdminProductsEdit::class)->name('products.edit'); + + Route::get('collections', AdminCollectionsIndex::class)->name('collections.index'); + Route::get('collections/create', AdminCollectionsEdit::class)->name('collections.create'); + Route::get('collections/{collection}/edit', AdminCollectionsEdit::class)->name('collections.edit'); + + Route::get('orders', AdminOrdersIndex::class)->name('orders.index'); + Route::get('orders/{order}', AdminOrdersShow::class)->name('orders.show'); + + Route::get('customers', AdminCustomersIndex::class)->name('customers.index'); + Route::get('customers/{customer}', AdminCustomersShow::class)->name('customers.show'); + + Route::get('discounts', AdminDiscountsIndex::class)->name('discounts.index'); + Route::get('discounts/create', AdminDiscountsEdit::class)->name('discounts.create'); + Route::get('discounts/{discount}/edit', AdminDiscountsEdit::class)->name('discounts.edit'); + + Route::get('pages', AdminPagesIndex::class)->name('pages.index'); + Route::get('pages/create', AdminPagesEdit::class)->name('pages.create'); + Route::get('pages/{page}/edit', AdminPagesEdit::class)->name('pages.edit'); + + Route::get('themes', AdminThemesIndex::class)->name('themes.index'); + + Route::get('settings', AdminSettingsGeneral::class)->name('settings.general'); + Route::get('settings/shipping', AdminSettingsShipping::class)->name('settings.shipping'); + Route::get('settings/taxes', AdminSettingsTaxes::class)->name('settings.taxes'); + Route::get('settings/staff', AdminSettingsStaff::class)->name('settings.staff'); + + Route::livewire('settings/webhooks', AdminWebhooksIndex::class)->name('settings.webhooks.index'); + Route::livewire('settings/webhooks/create', AdminWebhookEdit::class)->name('settings.webhooks.create'); + Route::livewire('settings/webhooks/{subscription}/edit', AdminWebhookEdit::class)->name('settings.webhooks.edit'); + Route::livewire('settings/webhooks/{subscription}/deliveries', AdminWebhookDeliveries::class)->name('settings.webhooks.deliveries'); + }); +}); + +Route::middleware('store.resolve:storefront')->group(function (): void { + Route::prefix('account')->name('account.')->group(function (): void { + Route::livewire('login', StorefrontLogin::class)->name('login'); + Route::livewire('register', StorefrontRegister::class)->name('register'); + Route::livewire('forgot-password', StorefrontForgotPassword::class)->name('password.request'); + Route::livewire('reset-password/{token}', StorefrontResetPassword::class)->name('password.reset'); + Route::livewire('set-password', StorefrontSetPassword::class)->name('password.set'); + Route::get('email/verify/{id}/{hash}', StorefrontEmailVerify::class)->name('verification.verify'); + Route::match(['get', 'post'], 'logout', StorefrontLogout::class)->name('logout'); + + Route::middleware('auth:customer')->group(function (): void { + Route::livewire('/', StorefrontAccountDashboard::class)->name('dashboard'); + Route::livewire('orders', StorefrontAccountOrdersIndex::class)->name('orders.index'); + Route::livewire('orders/{orderNumber}', StorefrontAccountOrdersShow::class)->name('orders.show'); + Route::livewire('addresses', StorefrontAccountAddresses::class)->name('addresses'); + Route::livewire('profile', StorefrontAccountProfile::class)->name('profile'); + }); + }); + + Route::livewire('/', StorefrontHome::class)->name('home'); + Route::livewire('/search', StorefrontSearch::class)->name('storefront.search'); + Route::livewire('/collections/{handle}', StorefrontCollectionShow::class)->name('storefront.collections.show'); + Route::livewire('/products/{handle}', StorefrontProductShow::class)->name('storefront.products.show'); + Route::livewire('/pages/{handle}', StorefrontPageShow::class)->name('storefront.pages.show'); + + Route::livewire('/cart', StorefrontCartShow::class)->name('storefront.cart.show'); + Route::livewire('/checkout', StorefrontCheckoutShow::class)->name('storefront.checkout.show'); + Route::livewire('/checkout/success', StorefrontCheckoutSuccess::class)->name('storefront.checkout.success'); +}); diff --git a/specs/progress.md b/specs/progress.md new file mode 100644 index 00000000..91b0083f --- /dev/null +++ b/specs/progress.md @@ -0,0 +1,112 @@ +# Shop Implementation Progress + +Started: 2026-04-17 +Branch: 2026-04-16-claude-code-opus-4-7-strict-prompt +Shop URL: http://shop.test/ + +## Status Legend +- [ ] Not started +- [~] In progress +- [x] Done (implemented + tested) + +## Phases + +### Phase 1: Foundation +- [x] 1.1 Environment and Config +- [x] 1.2 Core Migrations (organizations, stores, store_domains, store_users, store_settings, users modifications) +- [x] 1.3 Core Models + factories + seeders +- [x] 1.4 Enums (StoreStatus, StoreUserRole, StoreDomainType) +- [x] 1.5 Tenant Resolution Middleware (ResolveStore) +- [x] 1.6 BelongsToStore trait + StoreScope +- [x] 1.7 Authentication (admin + customer guards) +- [x] 1.8 Authorization policies + +### Phase 2: Catalog +- [x] Products / Variants / Options / Inventory / Collections / Media + +### Phase 12: Full Pest Test Suite +- [x] 12: Installed pestphp/pest-plugin-browser; added tests/Browser/ with SmokeTest + Admin/AdminLoginTest + Storefront/{StorefrontBrowseTest, StorefrontProductTest, CheckoutSuccessTest, CheckoutDeclineTest, CustomerAccountTest} (13 browser tests). Seeder seeds 127.0.0.1 and localhost as extra storefront domains in testing env so Pest's built-in test server resolves to the Shop store. Added missing storefront Add to Cart UI on product detail (Show livewire component + blade) plus name/id attributes on checkout form fields so browser locators work. Renamed login button text to "Log in" to avoid collision with the "Sign in" heading when using text-based click selectors. Added supplemental feature tests (AdminProductCreateTest, AdminOrderCaptureTest, AdminOrderFulfillTest, AdminOrderRefundTest, PoliciesTest, Api/ValidationRulesTest dataset-driven). Final green total: 255 tests, 575 assertions (242 feature + unit, 13 browser). + +### Phase 3: Themes / Pages / Navigation / Storefront Layout +- [x] Themes + CMS + Storefront Blade layouts + +### Phase 4: Cart / Checkout / Discounts / Shipping / Taxes +- [x] customers, customer_addresses, carts, cart_lines, checkouts, shipping_zones, shipping_rates, tax_settings, discounts migrations + models + factories +- [x] Customer model (Authenticatable), CustomerUserProvider wired to the real model +- [x] CartService, DiscountService, ShippingCalculator, TaxCalculator, PricingEngine, CheckoutService +- [x] PricingResult / TaxLine / DiscountResult value objects +- [x] Storefront Livewire: Cart\Show, Cart\Drawer, Checkout\Show, Checkout\Success + routes /cart /checkout /checkout/success +- [x] Pest feature tests for cart/discount/shipping/tax/pricing/checkout services + cart Livewire page + +### Phase 5: Payments / Orders / Fulfillment +- [x] orders, order_lines, payments, refunds, fulfillments, fulfillment_lines migrations + models + factories +- [x] Enums: OrderStatus, FinancialStatus, FulfillmentStatus, PaymentStatus, RefundStatus, FulfillmentShipmentStatus +- [x] PaymentProvider contract + MockPaymentProvider (magic cards: 4242... success, 4000...0002 decline, 4000...9995 insufficient_funds, 5555...4444 success) +- [x] OrderService, PaymentService, RefundService, FulfillmentService +- [x] Events: OrderCreated, OrderPaid, OrderCancelled, OrderRefunded, OrderFulfilled, FulfillmentCreated, FulfillmentShipped, FulfillmentDelivered +- [x] Checkout Livewire `place()` wires authorize -> createFromCheckout -> recordPayment -> redirect /checkout/success?order={order_number} +- [x] Admin orders list + detail placeholder Livewire pages at /admin/orders and /admin/orders/{order} +- [x] Pest tests: card success/decline/insufficient_funds, bank_transfer pending order, digital auto-fulfill, idempotent authorize, refund partial/full/restock/reject, fulfillment state machine and guard + +### Phase 6: Customer Accounts +- [x] Customer auth Livewire (Login, Register, Logout, ForgotPassword, ResetPassword, SetPassword, EmailVerify) +- [x] Account area Livewire (Dashboard, Orders\Index, Orders\Show, Addresses CRUD, Profile) +- [x] Custom CustomerTokenRepository scoped by store_id; PasswordBrokerManager override routing the 'customers' broker through it +- [x] CustomerWelcomeNotification + CustomerResetPasswordNotification (customer guard implements CanResetPassword) +- [x] OrderService::createFromCheckout now resolves or creates a Customer by (store_id, email) and sends CustomerWelcomeNotification on guest creation +- [x] Routes: /account/login, /register, /logout, /forgot-password, /reset-password/{token}, /set-password, /email/verify/{id}/{hash} + auth:customer /account (dashboard), /account/orders, /account/orders/{orderNumber}, /account/addresses, /account/profile +- [x] Pest feature tests (tests/Feature/Account): registration (per-store email uniqueness, cross-store reuse), login (success/failure/rate limit), logout, forgot+reset full flow, guest checkout creates customer + welcome, set-password activates guest account, orders index scoped to customer, address CRUD, email verification + +### Phase 7: Admin Panel +- [x] Admin layout shell (fixed sidebar, topbar with store switcher + profile dropdown, Flux-based, dark mode aware) +- [x] Dashboard (revenue today, orders today, AOV, visits today, recent 10 orders) +- [x] Products (index with search/status filter, create form, edit form with variants table) +- [x] Collections (index with search, create/edit form) +- [x] Orders (index with search + status filter, polished Show with fulfill/refund/cancel + bank-transfer confirm) +- [x] Customers (index with search + totals, show with order history and addresses) +- [x] Discounts (index, create/edit with type, value type, dates, usage limit, status) +- [x] Pages (index, create/edit with status workflow) +- [x] Themes (index grid + publish action) +- [x] Settings (general store settings, shipping zones list, taxes mode/provider, staff invite modal) +- [x] Store switcher writes session('current_store_id') and redirects to /admin +- [x] Routes registered under /admin with store.resolve:admin middleware and named admin.* +- [x] Authorization via existing policies in mount()/actions (Support = read-only, Staff = edit, Owner/Admin full) +- [x] OwnerUserSeeder seeds owner@shop.test (password: password) with Owner role on the default store +- [x] Pest tests at tests/Feature/Admin/ covering index + show + edit 200s and authorization denials + +### Phase 8: Search +- [x] SQLite FTS5 + SearchService + storefront /search + search_queries logging + +### Phase 9: Analytics +- [x] analytics_events + analytics_daily tables, AnalyticsService with client_event_id idempotency, DashboardMetricsService helper, analytics:rollup command (scheduled daily 01:00), storefront page_view/product_view hooks, OrderPaid -> checkout_completed listener + +### Phase 10: Apps & Webhooks +- [x] apps, app_installations, webhook_subscriptions, webhook_deliveries migrations + models + factories (encrypted signing_secret) +- [x] WebhookTopic enum (order.created/paid/fulfilled/cancelled/refunded, fulfillment.created/shipped, customer.created, product.created/updated/deleted, checkout.completed) +- [x] WebhookDispatcher service scoped by store + event_type + active status +- [x] DeliverWebhook queued job (tries=8, backoff 30/60/120/300/900/3600/7200s) with HMAC-SHA256 signature, X-Shop-* headers, WebhookDelivery row per attempt, circuit breaker pausing subscription after 5 consecutive failures +- [x] CustomerCreated event + Customer observer; DispatchWebhooks listener mapping Order/Fulfillment/Customer events to topics via EventServiceProvider +- [x] Admin Livewire: Settings\Webhooks\Index, Edit, Deliveries + routes under /admin/settings/webhooks + +### Phase 11: Polish +- [x] 11d: Admin Dashboard wired to DashboardMetricsService::forDay() (revenue, orders, AOV, visits, add-to-cart, checkout started, checkout completed) +- [x] 11d: Products\Index search delegates to SearchService::search() when query has >= 2 chars; falls back to paginated list otherwise +- [x] 11d: Admin sidebar adds a Webhooks link under System pointing to /admin/settings/webhooks +- [x] 11d: Admin layout gains an Alpine-driven toast container listening for the 'toast' window event; session flash status/error auto-dispatch on page load +- [x] 11d: Focus-visible rings on sidebar anchors; dark mode parity maintained +- [x] 11c: Error views (errors/404, 403, 500, 503) rendering inside the storefront layout when a store is resolved, else a minimal standalone fallback +- [x] 11c: Announcement bar driven by StoreSettings.settings_json['announcement'] with seeded fallback and explicit enabled flag +- [x] 11c: Storefront header swapped to use ; Drawer expanded to an aria-modal dialog with items + subtotal + checkout CTA + wire:loading states +- [x] 11c: Skip-to-content link, role=main/contentinfo landmarks, aria-label on nav regions, aria-current on active home link, aria-live flash region +- [x] 11b: DatabaseSeeder consolidated per specs/07 - 1 org + store with shop.test (storefront) + admin.shop.test (admin) domains, 4 admin users (owner/admin/staff/support@shop.test, pw=password) with matching StoreUser roles, 18 products + 5 collections + ProductMedia, 8 customers + addresses, 12 orders spanning paid/unfulfilled + paid/partial + fulfilled (paypal + ups) + pending bank transfer + cancelled + refunded + partially refunded, 5 discounts (WELCOME10/FREESHIP/SALE20/expired SPRING23/exhausted LIMITED50), shipping zones Domestic + International with 3 rates, tax settings row, 14 days of analytics_events (~7500 rows) plus matching analytics_daily rollups, 2 webhook subscriptions pointed at example.com. Dropped unused StoreSeeder/OrganizationSeeder/OwnerUserSeeder. +- [x] 11a: Sanctum installed (config + personal_access_tokens migration). HasApiTokens on User + Customer. routes/api.php wired via bootstrap/app.php with /api prefix; sanctum guard added to config/auth.php. Storefront API /api/storefront/v1 under store.resolve:storefront (carts index/show/addLine/updateLine/removeLine, checkouts create/show/address/shipping-method/apply-discount/remove-discount/pay, orders/{orderNumber}, search, analytics/events). Admin API /api/admin/v1 under auth:sanctum (stores/{id}/products CRUD with token ability check, collections, orders, customers, webhook-subscriptions). Resources in app/Http/Resources (Product, ProductVariant, ProductMedia, Collection, Customer, Order, OrderLine, Cart, CartLine, Checkout, WebhookSubscription). FormRequests in app/Http/Requests/Api (AddCartLine, UpdateCartLine, SetCheckoutAddress, SetShippingMethod, ApplyDiscount, PayCheckout, StoreProduct, UpdateProduct). Controllers delegate to existing service classes (CartService, CheckoutService, OrderService, PaymentService, ProductService, SearchService, AnalyticsService). 18 new Pest feature tests in tests/Feature/Api covering CRUD + validation + auth scoping; full suite 219 passing. + +## Log +- 2026-04-17: Starting implementation with team mode. +- 2026-04-17: Phase 1 complete. Kept users.password column name (not renamed to password_hash) to preserve Fortify starter-kit tests; override not required. Added /admin and /account route groups alongside existing Fortify routes. Customer guard registered via CustomerUserProvider which falls back to User model until Phase 6 introduces Customer. +- 2026-04-17: Phase 3 complete. Added themes/theme_files/theme_settings/pages/navigation_menus/navigation_items migrations (CHECK triggers on status/type enums), Eloquent models with BelongsToStore trait where applicable, factories, DefaultStoreSeeder creating shop.test hostname, ThemeSeeder/PageSeeder/NavigationSeeder seeding default theme plus about/contact pages and main-menu. Added storefront Blade layout component at resources/views/components/layouts/storefront.blade.php with announcement bar, header (logo, navigation, cart, account links), main slot, footer. Created class-based Livewire components under App\Livewire\Storefront (Home, Collections\Show, Products\Show, Pages\Show, Navigation partial). Routes wired inside the storefront middleware group. Pages show renders 404 for non-published. Full test suite green (62 passed). +- 2026-04-17: Phase 7 complete. Added admin layout (components/layouts/admin.blade.php) with fixed Flux sidebar, topbar store switcher, profile menu; full set of Admin\* Livewire components (Dashboard, Products Index/Create/Edit, Collections Index/Edit, Orders Index/Show w/ fulfill+refund+cancel, Customers Index/Show, Discounts Index/Edit, Pages Index/Edit, Themes Index w/ publish, Settings General/Shipping/Taxes/Staff). Routes under /admin using Route::get('path', Component::class)->name(...) pattern with store.resolve:admin middleware; /admin/switch-store/{store} writes session('current_store_id'). Authorization via existing policies in mount()/action bodies. OwnerUserSeeder adds owner@shop.test/password linked to shop store as Owner. 20 new tests in tests/Feature/Admin covering index/show/edit 200 responses, authorization denials for Support/Staff, and the store switcher flow; full suite 190 passing. +- 2026-04-17: Phase 4 complete. Added 9 migrations and enums (CartStatus, CheckoutStatus, PaymentMethod, DiscountType, DiscountValueType, DiscountStatus, ShippingRateType, TaxMode, TaxProviderType). Customer model now extends Authenticatable, implements BelongsToStore, overrides getAuthPassword() to return password_hash; CustomerUserProvider references the real model. New services: CartService (optimistic cart_version locking, inventory-aware addLine/updateLineQuantity/removeLine/mergeOnLogin), DiscountService (case-insensitive code lookup, allocation with largest-remainder, status/usage/rules validation throwing InvalidDiscountException with reason codes), ShippingCalculator (zone matching by country + region with specificity tiebreak, flat/weight/price rate calculators, skip-for-digital-only cart), TaxCalculator (manual provider with inclusive extraction via intdiv and exclusive additive rates, shipping_taxable flag), PricingEngine (pipeline: subtotal -> discount -> shipping -> tax -> total, snapshots to totals_json), CheckoutService (state transitions with inventory reservation on payment selection). Added storefront cart page, cart drawer stub, checkout multi-section view, success page + routes. CommerceSeeder wired additively into DatabaseSeeder. 46 new Pest tests covering happy and failure paths; full suite 120 passing. +- 2026-04-17: Phase 11d complete. Admin Dashboard now pulls KPIs from App\Services\DashboardMetricsService::forDay() (keys: revenue_amount, orders_count, aov_amount, visits_count, add_to_cart_count, checkout_started_count, checkout_completed_count) with a new funnel tile below the KPIs. Admin\Products\Index search now delegates to App\Services\SearchService::search() for queries >= 2 chars (IDs are re-applied to the paginated Product query so filters still compose); shorter inputs fall back to the plain list. Added Webhooks link under the System group in resources/views/components/layouts/admin.blade.php pointing to /admin/settings/webhooks. Layout now renders a fixed top-right Alpine toast container listening on the window 'toast' event; session('status') and session('error') auto-dispatch toasts on page load so existing session flashes continue to work without edits. Added focus-visible ring utilities to sidebar anchors. Two new tests in tests/Feature/Admin/ProductsSearchTest verify the SearchService delegation and short-query fallback via mocked service. All 22 admin tests green; full suite 200 passing (1 unrelated Polish\AnnouncementBarTest failure owned by Phase 11c). Created minimal routes/api.php stub (Sanctum /user) to unblock the framework boot referenced by bootstrap/app.php; Phase 11a will replace with full API. +- 2026-04-17: Phase 6 complete. Customer is now CanResetPassword; 'customers' password broker overridden via a PasswordBrokerManager extension that uses CustomerTokenRepository (scopes tokens by (email, store_id) so the same email on two stores does not collide). New Livewire components under App\Livewire\Storefront\Account (Dashboard, Profile, Addresses, Orders\Index, Orders\Show) and App\Livewire\Storefront\Account\Auth (Login hardened with per-email rate limiting, Register, Logout, ForgotPassword, ResetPassword, SetPassword, EmailVerify). OrderService::createFromCheckout now attaches or creates a Customer by (store_id, email) and, when the customer was newly created guest, dispatches CustomerWelcomeNotification containing a reset link to /account/set-password?token=... which lets the guest set a password and auto-verifies their email. Routes added under the /account prefix inside the storefront middleware group. 16 new Pest feature tests in tests/Feature/Account/; full suite 170 passing. +- 2026-04-17: Phase 11c complete. Added resources/views/errors/{404,403,500,503}.blade.php which delegate to a shared errors/layout.blade.php; when app()->bound('current_store') the page renders inside x-layouts.storefront, otherwise a minimal standalone HTML body so unresolved-host responses still return a friendly page. Storefront layout rewritten: announcement bar is now backed by $currentStore->settings->settings_json['announcement'] (enabled/text/link) with a 'Free shipping on orders over $50' fallback; the Cart link was replaced by ; added session-status aria-live flash region; sweeping focus-visible:ring utilities on nav/footer/drawer interactive elements; aria-current='page' on the home link when path is '/'. Cart\Drawer now toggles an aria-modal dialog panel listing cart lines, subtotal, a Checkout CTA, a View full cart link, plus wire:loading markers on toggle/refreshDrawer. 9 new Pest tests under tests/Feature/Polish/ covering homepage resolves at http://shop.test/, 404 + 503 error pages, minimal fallback for unknown host, skip-link and landmark assertions, aria-current on homepage, plus StoreSettings-driven announcement (configured/default/disabled). Full suite 201 passed. diff --git a/tests/Browser/Admin/AdminLoginTest.php b/tests/Browser/Admin/AdminLoginTest.php new file mode 100644 index 00000000..31a36b2a --- /dev/null +++ b/tests/Browser/Admin/AdminLoginTest.php @@ -0,0 +1,28 @@ +seed(DatabaseSeeder::class); +}); + +it('allows an owner to sign in and reach the admin dashboard', function (): void { + $owner = User::query()->where('email', 'owner@shop.test')->firstOrFail(); + $store = Store::query()->first(); + + $page = visit('/admin/login') + ->assertSee('Admin sign in') + ->fill('email', 'owner@shop.test') + ->fill('password', 'password') + ->click('Log in') + ->wait(2); + + // Select the store after login (admin dashboard requires current_store_id) + $page->navigate('/admin/switch-store/'.$store->getKey()) + ->wait(1); + + $page->assertPathIs('/admin') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/SmokeTest.php b/tests/Browser/SmokeTest.php new file mode 100644 index 00000000..cfd4d213 --- /dev/null +++ b/tests/Browser/SmokeTest.php @@ -0,0 +1,43 @@ +seed(DatabaseSeeder::class); +}); + +it('loads the storefront home page', function (): void { + $page = visit('/'); + + $page->assertSee('Shop') + ->assertNoJavaScriptErrors(); +}); + +it('loads a collection page', function (): void { + $page = visit('/collections/featured'); + + $page->assertSee('Featured') + ->assertNoJavaScriptErrors(); +}); + +it('loads a product detail page', function (): void { + $page = visit('/products/classic-tee'); + + $page->assertSee('Classic Tee') + ->assertSee('Add to Cart') + ->assertNoJavaScriptErrors(); +}); + +it('loads the cart page', function (): void { + $page = visit('/cart'); + + $page->assertSee('Your cart') + ->assertNoJavaScriptErrors(); +}); + +it('loads the admin login page', function (): void { + $page = visit('/admin/login'); + + $page->assertSee('Admin sign in') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CheckoutDeclineTest.php b/tests/Browser/Storefront/CheckoutDeclineTest.php new file mode 100644 index 00000000..12aa31c4 --- /dev/null +++ b/tests/Browser/Storefront/CheckoutDeclineTest.php @@ -0,0 +1,39 @@ +seed(DatabaseSeeder::class); + WebhookSubscription::query()->withoutGlobalScopes()->delete(); +}); + +it('rejects a checkout when using the decline magic card', function (): void { + $page = visit('/products/classic-tee') + ->click('Add to Cart') + ->wait(1); + + $page->navigate('/checkout') + ->assertSee('Checkout') + ->fill('email', 'customer@example.com') + ->fill('first_name', 'Jane') + ->fill('last_name', 'Doe') + ->fill('address1', '123 Main St') + ->fill('city', 'Springfield') + ->fill('province_code', 'IL') + ->fill('country_code', 'US') + ->fill('postal_code', '62701') + ->click('Save address') + ->wait(1); + + $page->click('Standard') + ->wait(1); + + $page->fill('card_number', '4000000000000002') + ->click('Place order') + ->wait(2); + + $page->assertPathIs('/checkout') + ->assertSee('card_declined') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CheckoutSuccessTest.php b/tests/Browser/Storefront/CheckoutSuccessTest.php new file mode 100644 index 00000000..a24b84dc --- /dev/null +++ b/tests/Browser/Storefront/CheckoutSuccessTest.php @@ -0,0 +1,41 @@ +seed(DatabaseSeeder::class); + WebhookSubscription::query()->withoutGlobalScopes()->delete(); + Http::fake(); +}); + +it('completes a checkout with the magic success card', function (): void { + $page = visit('/products/classic-tee') + ->click('Add to Cart') + ->wait(1); + + $page->navigate('/checkout') + ->assertSee('Checkout') + ->fill('email', 'customer@example.com') + ->fill('first_name', 'Jane') + ->fill('last_name', 'Doe') + ->fill('address1', '123 Main St') + ->fill('city', 'Springfield') + ->fill('province_code', 'IL') + ->fill('country_code', 'US') + ->fill('postal_code', '62701') + ->click('Save address') + ->wait(1); + + $page->click('Standard') + ->wait(1); + + $page->fill('card_number', '4242424242424242') + ->click('Place order') + ->wait(3); + + $page->assertPathBeginsWith('/checkout/success') + ->assertSee('Order') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/CustomerAccountTest.php b/tests/Browser/Storefront/CustomerAccountTest.php new file mode 100644 index 00000000..2ef6b5c2 --- /dev/null +++ b/tests/Browser/Storefront/CustomerAccountTest.php @@ -0,0 +1,47 @@ +seed(DatabaseSeeder::class); +}); + +it('registers a new customer and lands on the account dashboard', function (): void { + $page = visit('/account/register') + ->assertSee('Create an account') + ->fill('name', 'Test Buyer') + ->fill('email', 'buyer@example.com') + ->fill('password', 'secret-password') + ->fill('password_confirmation', 'secret-password') + ->click('Create account') + ->wait(2); + + $page->assertPathIs('/account') + ->assertNoJavaScriptErrors(); + + expect(Customer::query()->where('email', 'buyer@example.com')->exists())->toBeTrue(); +}); + +it('allows a customer to log in with valid credentials', function (): void { + $store = Store::query()->first(); + + Customer::query()->create([ + 'store_id' => $store->getKey(), + 'email' => 'existing@example.com', + 'password_hash' => Hash::make('secret-password'), + 'name' => 'Existing Customer', + 'email_verified_at' => now(), + ]); + + $page = visit('/account/login') + ->fill('email', 'existing@example.com') + ->fill('password', 'secret-password') + ->click('Log in') + ->wait(3); + + $page->assertPathIs('/account') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/StorefrontBrowseTest.php b/tests/Browser/Storefront/StorefrontBrowseTest.php new file mode 100644 index 00000000..bdfc5cce --- /dev/null +++ b/tests/Browser/Storefront/StorefrontBrowseTest.php @@ -0,0 +1,23 @@ +seed(DatabaseSeeder::class); +}); + +it('renders the home page with the store name', function (): void { + $page = visit('/'); + + $page->assertSee('Welcome to Shop') + ->assertSee('Shop the collection') + ->assertNoJavaScriptErrors(); +}); + +it('shows products when visiting a collection', function (): void { + $page = visit('/collections/featured'); + + $page->assertSee('Featured') + ->assertSee('Classic Tee') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Browser/Storefront/StorefrontProductTest.php b/tests/Browser/Storefront/StorefrontProductTest.php new file mode 100644 index 00000000..f83bae07 --- /dev/null +++ b/tests/Browser/Storefront/StorefrontProductTest.php @@ -0,0 +1,20 @@ +seed(DatabaseSeeder::class); +}); + +it('adds a variant to cart from product detail page', function (): void { + $page = visit('/products/classic-tee'); + + $page->assertSee('Classic Tee') + ->assertSee('Add to Cart') + ->click('Add to Cart') + ->wait(1) + ->navigate('/cart') + ->assertSee('Classic Tee') + ->assertSee('Subtotal') + ->assertNoJavaScriptErrors(); +}); diff --git a/tests/Feature/Account/AddressCrudTest.php b/tests/Feature/Account/AddressCrudTest.php new file mode 100644 index 00000000..2d576d18 --- /dev/null +++ b/tests/Feature/Account/AddressCrudTest.php @@ -0,0 +1,52 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->create(['store_id' => $store->getKey()]); + $this->actingAs($customer, 'customer'); + + $this->get('http://shop.test/account/addresses'); + + Livewire::test(Addresses::class) + ->set('label', 'Home') + ->set('first_name', 'Jane') + ->set('last_name', 'Shopper') + ->set('address1', '12 Main St') + ->set('city', 'Townville') + ->set('country_code', 'US') + ->set('postal_code', '12345') + ->set('is_default', true) + ->call('save'); + + $address = CustomerAddress::query()->where('customer_id', $customer->getKey())->firstOrFail(); + expect($address->is_default)->toBeTrue() + ->and(($address->address_json ?? [])['address1'])->toBe('12 Main St'); + + Livewire::test(Addresses::class) + ->call('edit', $address->getKey()) + ->set('city', 'Newer Town') + ->call('save'); + + $address->refresh(); + expect(($address->address_json ?? [])['city'])->toBe('Newer Town'); + + Livewire::test(Addresses::class)->call('delete', $address->getKey()); + + expect(CustomerAddress::query()->where('customer_id', $customer->getKey())->count())->toBe(0); +}); diff --git a/tests/Feature/Account/EmailVerifyTest.php b/tests/Feature/Account/EmailVerifyTest.php new file mode 100644 index 00000000..5a022cfc --- /dev/null +++ b/tests/Feature/Account/EmailVerifyTest.php @@ -0,0 +1,46 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'verify@example.com', + 'email_verified_at' => null, + ]); + + $hash = sha1('verify@example.com'); + $response = $this->actingAs($customer, 'customer') + ->get('http://shop.test/account/email/verify/'.$customer->getKey().'/'.$hash); + + $response->assertRedirect('/account'); + $customer->refresh(); + expect($customer->email_verified_at)->not->toBeNull(); +}); + +it('rejects a bad hash', function () { + $store = Store::factory()->create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email_verified_at' => null]); + + $response = $this->get('http://shop.test/account/email/verify/'.$customer->getKey().'/bad-hash'); + + $response->assertForbidden(); +}); diff --git a/tests/Feature/Account/GuestCheckoutCustomerTest.php b/tests/Feature/Account/GuestCheckoutCustomerTest.php new file mode 100644 index 00000000..f1a1e5d3 --- /dev/null +++ b/tests/Feature/Account/GuestCheckoutCustomerTest.php @@ -0,0 +1,124 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1000, + 'requires_shipping' => 0, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + 'quantity_reserved' => 0, + ]); + + $cart = Cart::query()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => null, + 'currency' => 'USD', + 'status' => CartStatus::Active->value, + ]); + CartLine::query()->create([ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + 'unit_price_amount' => 1000, + 'line_total_amount' => 1000, + 'line_discount_amount' => 0, + ]); + + $checkout = Checkout::query()->create([ + 'store_id' => $store->getKey(), + 'cart_id' => $cart->getKey(), + 'customer_id' => null, + 'status' => CheckoutStatus::PaymentSelected->value, + 'email' => 'guest@example.com', + 'shipping_address_json' => [ + 'first_name' => 'Guest', + 'last_name' => 'Shopper', + 'address1' => '1 Test St', + 'city' => 'Townsville', + 'country_code' => 'US', + 'postal_code' => '12345', + ], + 'billing_address_json' => [], + 'payment_method' => PaymentMethod::CreditCard->value, + 'totals_json' => ['subtotal' => 1000, 'discount' => 0, 'shipping' => 0, 'tax_total' => 0, 'total' => 1000], + ]); + + app()->instance('current_store', $store); + + $order = app(OrderService::class)->createFromCheckout($checkout); + + expect($order->customer_id)->not->toBeNull(); + + $customer = Customer::query()->withoutGlobalScopes()->find($order->customer_id); + expect($customer)->not->toBeNull() + ->and($customer->email)->toBe('guest@example.com') + ->and($customer->password_hash)->toBeNull() + ->and($customer->hasPassword())->toBeFalse(); + + Notification::assertSentTo($customer, CustomerWelcomeNotification::class); +}); + +it('lets a guest customer set a password via the set-password link', function () { + $store = Store::factory()->create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->guest()->create([ + 'store_id' => $store->getKey(), + 'email' => 'guest@example.com', + ]); + + $this->get('http://shop.test/account/set-password'); + + $token = Password::broker('customers')->createToken($customer); + + Livewire::test(SetPassword::class, ['token' => $token, 'email' => 'guest@example.com']) + ->set('password', 'brandnew1') + ->set('password_confirmation', 'brandnew1') + ->call('setPassword'); + + $customer->refresh(); + expect(Hash::check('brandnew1', $customer->password_hash))->toBeTrue() + ->and($customer->email_verified_at)->not->toBeNull(); +}); diff --git a/tests/Feature/Account/LoginTest.php b/tests/Feature/Account/LoginTest.php new file mode 100644 index 00000000..c747b6c0 --- /dev/null +++ b/tests/Feature/Account/LoginTest.php @@ -0,0 +1,99 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +beforeEach(function () { + RateLimiter::clear('customer-login:127.0.0.1:valid@example.com'); + RateLimiter::clear('customer-login:127.0.0.1:wrong@example.com'); +}); + +it('signs in a customer with valid credentials', function () { + $store = seedLoginStore(); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'valid@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + + $this->get('http://shop.test/account/login'); + + Livewire::test(Login::class) + ->set('email', 'valid@example.com') + ->set('password', 'secret123') + ->call('authenticate') + ->assertHasNoErrors(); + + expect(Auth::guard('customer')->check())->toBeTrue() + ->and(Auth::guard('customer')->id())->toBe($customer->getKey()); +}); + +it('rejects invalid credentials', function () { + $store = seedLoginStore(); + Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'valid@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + + $this->get('http://shop.test/account/login'); + + Livewire::test(Login::class) + ->set('email', 'valid@example.com') + ->set('password', 'wrong-pass') + ->call('authenticate') + ->assertHasErrors('email'); + + expect(Auth::guard('customer')->check())->toBeFalse(); +}); + +it('rate-limits after 5 failed attempts', function () { + $store = seedLoginStore(); + Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'wrong@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + + $this->get('http://shop.test/account/login'); + + for ($i = 0; $i < 5; $i++) { + Livewire::test(Login::class) + ->set('email', 'wrong@example.com') + ->set('password', 'wrong'.$i) + ->call('authenticate') + ->assertHasErrors('email'); + } + + $component = Livewire::test(Login::class) + ->set('email', 'wrong@example.com') + ->set('password', 'still-wrong') + ->call('authenticate'); + + $component->assertHasErrors('email'); + + $messages = $component->errors()->get('email'); + expect(implode(' ', $messages))->toContain('Too many attempts'); +}); diff --git a/tests/Feature/Account/LogoutTest.php b/tests/Feature/Account/LogoutTest.php new file mode 100644 index 00000000..2dd132df --- /dev/null +++ b/tests/Feature/Account/LogoutTest.php @@ -0,0 +1,33 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'me@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + + $this->get('http://shop.test/'); + Auth::guard('customer')->login($customer); + + $response = $this->post('http://shop.test/account/logout'); + + $response->assertRedirect('/'); + expect(Auth::guard('customer')->check())->toBeFalse(); +}); diff --git a/tests/Feature/Account/OrdersIndexTest.php b/tests/Feature/Account/OrdersIndexTest.php new file mode 100644 index 00000000..ee026faf --- /dev/null +++ b/tests/Feature/Account/OrdersIndexTest.php @@ -0,0 +1,71 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $me = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'me@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + $other = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'other@example.com', + 'password_hash' => Hash::make('secret123'), + ]); + + $mine = Order::factory()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $me->getKey(), + 'order_number' => '#1001', + ]); + Order::factory()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $other->getKey(), + 'order_number' => '#1002', + ]); + + $response = $this->actingAs($me, 'customer')->get('http://shop.test/account/orders'); + + $response->assertOk(); + $response->assertSee('#1001'); + $response->assertDontSee('#1002'); +}); + +it('404s when viewing another customers order', function () { + $store = Store::factory()->create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $me = Customer::factory()->create(['store_id' => $store->getKey()]); + $other = Customer::factory()->create(['store_id' => $store->getKey()]); + + Order::factory()->create([ + 'store_id' => $store->getKey(), + 'customer_id' => $other->getKey(), + 'order_number' => '#2002', + ]); + + $response = $this->actingAs($me, 'customer')->get('http://shop.test/account/orders/%232002'); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Account/PasswordResetTest.php b/tests/Feature/Account/PasswordResetTest.php new file mode 100644 index 00000000..aaaf2634 --- /dev/null +++ b/tests/Feature/Account/PasswordResetTest.php @@ -0,0 +1,57 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + $customer = Customer::factory()->create([ + 'store_id' => $store->getKey(), + 'email' => 'reset@example.com', + 'password_hash' => Hash::make('oldsecret1'), + ]); + + $this->get('http://shop.test/account/forgot-password'); + + Livewire::test(ForgotPassword::class) + ->set('email', 'reset@example.com') + ->call('sendLink'); + + $token = null; + Notification::assertSentTo($customer, CustomerResetPasswordNotification::class, function (CustomerResetPasswordNotification $n) use (&$token) { + $token = $n->token; + + return true; + }); + + expect($token)->not->toBeNull(); + + $this->get('http://shop.test/account/reset-password/'.$token.'?email=reset@example.com'); + + Livewire::test(ResetPassword::class, ['token' => $token]) + ->set('email', 'reset@example.com') + ->set('password', 'newsecret1') + ->set('password_confirmation', 'newsecret1') + ->call('resetPassword'); + + $customer->refresh(); + expect(Hash::check('newsecret1', $customer->password_hash))->toBeTrue(); +}); diff --git a/tests/Feature/Account/RegistrationTest.php b/tests/Feature/Account/RegistrationTest.php new file mode 100644 index 00000000..a11b00d7 --- /dev/null +++ b/tests/Feature/Account/RegistrationTest.php @@ -0,0 +1,77 @@ +create(['name' => 'Shop']); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('creates a customer scoped to the current store on registration', function () { + $store = seedAccountStore(); + + $this->withServerVariables(['HTTP_HOST' => 'shop.test'])->get('http://shop.test/account/register'); + + Livewire::withQueryParams([]) + ->test(Register::class) + ->set('name', 'Jane Shopper') + ->set('email', 'jane@example.com') + ->set('password', 'secret123') + ->set('password_confirmation', 'secret123') + ->call('register'); + + $customer = Customer::query()->withoutGlobalScopes()->where('email', 'jane@example.com')->first(); + + expect($customer)->not->toBeNull() + ->and($customer->store_id)->toBe($store->getKey()) + ->and($customer->password_hash)->not->toBeNull(); +}); + +it('allows the same email to register on two different stores', function () { + $storeA = seedAccountStore('shop-a.test'); + $storeB = Store::factory()->create(['name' => 'Shop B']); + StoreDomain::factory()->create([ + 'store_id' => $storeB->getKey(), + 'hostname' => 'shop-b.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $this->get('http://shop-a.test/account/register'); + Livewire::test(Register::class) + ->set('name', 'Dual User') + ->set('email', 'dual@example.com') + ->set('password', 'secret123') + ->set('password_confirmation', 'secret123') + ->call('register'); + + $this->get('http://shop-b.test/account/register'); + Livewire::test(Register::class) + ->set('name', 'Dual User B') + ->set('email', 'dual@example.com') + ->set('password', 'secret456') + ->set('password_confirmation', 'secret456') + ->call('register'); + + $aCount = Customer::query()->withoutGlobalScopes()->where('store_id', $storeA->getKey())->where('email', 'dual@example.com')->count(); + $bCount = Customer::query()->withoutGlobalScopes()->where('store_id', $storeB->getKey())->where('email', 'dual@example.com')->count(); + + expect($aCount)->toBe(1) + ->and($bCount)->toBe(1); +}); diff --git a/tests/Feature/Admin/AdminOrderCaptureTest.php b/tests/Feature/Admin/AdminOrderCaptureTest.php new file mode 100644 index 00000000..5bd42a55 --- /dev/null +++ b/tests/Feature/Admin/AdminOrderCaptureTest.php @@ -0,0 +1,53 @@ +withoutGlobalScopes()->delete(); + + $store = Store::factory()->create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + $order = Order::factory()->create([ + 'store_id' => $store->getKey(), + 'order_number' => '#3001', + 'status' => OrderStatus::Pending, + 'financial_status' => FinancialStatus::Pending, + 'payment_method' => PaymentMethod::BankTransfer, + ]); + + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'variant_id' => null, + 'quantity' => 1, + ]); + + $this->actingAs($user); + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + Livewire::test(\App\Livewire\Admin\Orders\Show::class, ['order' => $order->getKey()]) + ->call('confirmPayment') + ->assertHasNoErrors(); + + expect($order->fresh()->financial_status)->toBe(FinancialStatus::Paid); +}); diff --git a/tests/Feature/Admin/AdminOrderFulfillTest.php b/tests/Feature/Admin/AdminOrderFulfillTest.php new file mode 100644 index 00000000..ed70344a --- /dev/null +++ b/tests/Feature/Admin/AdminOrderFulfillTest.php @@ -0,0 +1,46 @@ +withoutGlobalScopes()->delete(); + + $store = Store::factory()->create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + $order = Order::factory()->paid()->create(['store_id' => $store->getKey()]); + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'variant_id' => null, + 'quantity' => 2, + ]); + + $this->actingAs($user); + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + Livewire::test(\App\Livewire\Admin\Orders\Show::class, ['order' => $order->getKey()]) + ->call('openFulfillmentModal') + ->set('fulfillmentLineQuantities.'.$line->getKey(), 2) + ->call('createFulfillment') + ->assertHasNoErrors(); + + expect($order->fresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); diff --git a/tests/Feature/Admin/AdminOrderRefundTest.php b/tests/Feature/Admin/AdminOrderRefundTest.php new file mode 100644 index 00000000..73a561de --- /dev/null +++ b/tests/Feature/Admin/AdminOrderRefundTest.php @@ -0,0 +1,62 @@ +withoutGlobalScopes()->delete(); + + $store = Store::factory()->create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + $order = Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'total_amount' => 5000, + ]); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'variant_id' => null, + 'quantity' => 1, + 'unit_price_amount' => 5000, + 'total_amount' => 5000, + ]); + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => PaymentMethod::CreditCard->value, + 'status' => PaymentStatus::Captured->value, + 'amount' => 5000, + 'currency' => 'USD', + ]); + + $this->actingAs($user); + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + Livewire::test(\App\Livewire\Admin\Orders\Show::class, ['order' => $order->getKey()]) + ->call('openRefundModal') + ->set('refundAmount', 5000) + ->set('refundReason', 'Customer request') + ->call('createRefund') + ->assertHasNoErrors(); + + expect($order->fresh()->financial_status)->toBe(FinancialStatus::Refunded); +}); diff --git a/tests/Feature/Admin/AdminProductCreateTest.php b/tests/Feature/Admin/AdminProductCreateTest.php new file mode 100644 index 00000000..bdef22d7 --- /dev/null +++ b/tests/Feature/Admin/AdminProductCreateTest.php @@ -0,0 +1,37 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + $this->actingAs($user)->withSession(['current_store_id' => $store->getKey()]); + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + Livewire::test(\App\Livewire\Admin\Products\Create::class) + ->set('title', 'Test Hoodie') + ->set('status', 'active') + ->set('priceAmount', 2999) + ->set('quantityOnHand', 10) + ->call('save') + ->assertHasNoErrors(); + + $this->get('/admin/products') + ->assertOk() + ->assertSee('Test Hoodie'); +}); diff --git a/tests/Feature/Admin/CollectionsTest.php b/tests/Feature/Admin/CollectionsTest.php new file mode 100644 index 00000000..0047beb3 --- /dev/null +++ b/tests/Feature/Admin/CollectionsTest.php @@ -0,0 +1,43 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + + return [$store, $user]; +} + +it('lists collections', function () { + [$store, $user] = collectionsSetup(); + Collection::factory()->create(['store_id' => $store->getKey(), 'title' => 'Summer Collection']); + + $this->actingAs($user)->get('/admin/collections') + ->assertOk() + ->assertSee('Summer Collection'); +}); + +it('edits an existing collection', function () { + [$store, $user] = collectionsSetup(); + $collection = Collection::factory()->create(['store_id' => $store->getKey(), 'title' => 'Winter Picks']); + + $this->actingAs($user)->get('/admin/collections/'.$collection->getKey().'/edit') + ->assertOk() + ->assertSee('Winter Picks'); +}); diff --git a/tests/Feature/Admin/DashboardTest.php b/tests/Feature/Admin/DashboardTest.php new file mode 100644 index 00000000..5f53a8d0 --- /dev/null +++ b/tests/Feature/Admin/DashboardTest.php @@ -0,0 +1,36 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + + return [$store, $user]; +} + +it('redirects guests away from admin', function () { + $this->get('/admin')->assertRedirect(); +}); + +it('shows the admin dashboard for owner', function () { + [, $user] = adminSetup(); + + $this->actingAs($user)->get('/admin') + ->assertOk() + ->assertSee('Dashboard'); +}); diff --git a/tests/Feature/Admin/MiscAdminPagesTest.php b/tests/Feature/Admin/MiscAdminPagesTest.php new file mode 100644 index 00000000..9d8c269b --- /dev/null +++ b/tests/Feature/Admin/MiscAdminPagesTest.php @@ -0,0 +1,104 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + + return [$store, $user]; +} + +it('lists customers', function () { + [$store, $user] = miscSetup(); + Customer::factory()->create(['store_id' => $store->getKey(), 'email' => 'shopper@example.com']); + + $this->actingAs($user)->get('/admin/customers') + ->assertOk() + ->assertSee('shopper@example.com'); +}); + +it('shows customer detail', function () { + [$store, $user] = miscSetup(); + $customer = Customer::factory()->create(['store_id' => $store->getKey(), 'email' => 'shopper@example.com']); + + $this->actingAs($user)->get('/admin/customers/'.$customer->getKey()) + ->assertOk() + ->assertSee('shopper@example.com'); +}); + +it('lists discounts', function () { + [$store, $user] = miscSetup(); + Discount::factory()->create(['store_id' => $store->getKey(), 'code' => 'SAVE10']); + + $this->actingAs($user)->get('/admin/discounts') + ->assertOk() + ->assertSee('SAVE10'); +}); + +it('opens discount create form', function () { + [, $user] = miscSetup(); + + $this->actingAs($user)->get('/admin/discounts/create') + ->assertOk() + ->assertSee('Create discount'); +}); + +it('lists pages', function () { + [$store, $user] = miscSetup(); + Page::factory()->create(['store_id' => $store->getKey(), 'title' => 'About Us']); + + $this->actingAs($user)->get('/admin/pages') + ->assertOk() + ->assertSee('About Us'); +}); + +it('lists themes', function () { + [$store, $user] = miscSetup(); + Theme::factory()->create(['store_id' => $store->getKey(), 'name' => 'Default']); + + $this->actingAs($user)->get('/admin/themes') + ->assertOk() + ->assertSee('Default'); +}); + +it('shows general settings', function () { + [, $user] = miscSetup(); + + $this->actingAs($user)->get('/admin/settings') + ->assertOk() + ->assertSee('Store details'); +}); + +it('shows staff settings', function () { + [, $user] = miscSetup(); + + $this->actingAs($user)->get('/admin/settings/staff') + ->assertOk() + ->assertSee('Staff'); +}); + +it('denies staff from general settings', function () { + [, $user] = miscSetup(StoreUserRole::Staff); + + $this->actingAs($user)->get('/admin/settings') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/OrdersAdminTest.php b/tests/Feature/Admin/OrdersAdminTest.php new file mode 100644 index 00000000..5069cac5 --- /dev/null +++ b/tests/Feature/Admin/OrdersAdminTest.php @@ -0,0 +1,93 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + + return [$store, $user]; +} + +function makeOrder(Store $store): Order +{ + return Order::factory()->create([ + 'store_id' => $store->getKey(), + 'order_number' => '#2001', + 'status' => OrderStatus::Paid, + 'financial_status' => FinancialStatus::Paid, + 'fulfillment_status' => FulfillmentStatus::Unfulfilled, + 'payment_method' => PaymentMethod::CreditCard, + 'total_amount' => 5000, + 'currency' => 'USD', + 'email' => 'buyer@example.com', + 'placed_at' => now(), + ]); +} + +it('lists orders', function () { + [$store, $user] = ordersSetup(); + makeOrder($store); + + $this->actingAs($user)->get('/admin/orders') + ->assertOk() + ->assertSee('#2001'); +}); + +it('shows an order detail', function () { + [$store, $user] = ordersSetup(); + $order = makeOrder($store); + OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'title_snapshot' => 'Blue Shirt', + 'quantity' => 1, + 'unit_price_amount' => 5000, + 'total_amount' => 5000, + ]); + + $this->actingAs($user)->get('/admin/orders/'.$order->getKey()) + ->assertOk() + ->assertSee('#2001') + ->assertSee('Blue Shirt'); +}); + +it('denies staff from refunding an order', function () { + [$store, $user] = ordersSetup(StoreUserRole::Staff); + $order = makeOrder($store); + Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'method' => PaymentMethod::CreditCard->value, + 'status' => PaymentStatus::Captured->value, + 'amount' => 5000, + 'currency' => 'USD', + ]); + + $this->actingAs($user); + + Livewire\Livewire::test(\App\Livewire\Admin\Orders\Show::class, ['order' => $order->getKey()]) + ->set('refundAmount', 1000) + ->call('createRefund') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/PoliciesTest.php b/tests/Feature/Admin/PoliciesTest.php new file mode 100644 index 00000000..2820f7a7 --- /dev/null +++ b/tests/Feature/Admin/PoliciesTest.php @@ -0,0 +1,59 @@ +create(); + $user = User::factory()->create(); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + return [$store, $user]; +} + +it('ProductPolicy allows owner to create and denies support', function (): void { + [$store, $owner] = makeStoreUser(StoreUserRole::Owner); + expect($owner->can('create', Product::class))->toBeTrue(); + + [$store2, $support] = makeStoreUser(StoreUserRole::Support); + expect($support->can('create', Product::class))->toBeFalse(); +}); + +it('OrderPolicy allows owner to create refunds and denies staff', function (): void { + [$store, $owner] = makeStoreUser(StoreUserRole::Owner); + $order = Order::factory()->create(['store_id' => $store->getKey()]); + expect($owner->can('createRefund', $order))->toBeTrue(); + + [$store2, $staff] = makeStoreUser(StoreUserRole::Staff); + $order2 = Order::factory()->create(['store_id' => $store2->getKey()]); + expect($staff->can('createRefund', $order2))->toBeFalse(); +}); + +it('PagePolicy allows staff to update pages', function (): void { + [$store, $staff] = makeStoreUser(StoreUserRole::Staff); + $page = Page::factory()->create(['store_id' => $store->getKey()]); + expect($staff->can('update', $page))->toBeTrue(); +}); + +it('DiscountPolicy denies support user to create a discount', function (): void { + [$store, $support] = makeStoreUser(StoreUserRole::Support); + expect($support->can('create', Discount::class))->toBeFalse(); +}); diff --git a/tests/Feature/Admin/ProductsSearchTest.php b/tests/Feature/Admin/ProductsSearchTest.php new file mode 100644 index 00000000..b2f0941f --- /dev/null +++ b/tests/Feature/Admin/ProductsSearchTest.php @@ -0,0 +1,77 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + app()->instance('current_store', $store); + + return [$store, $user]; +} + +it('delegates 2+ char queries to SearchService', function () { + [$store, $user] = searchSetup(); + $matching = Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Matching product', + 'status' => ProductStatus::Active, + ]); + Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Other thing', + 'status' => ProductStatus::Active, + ]); + + $fake = Mockery::mock(SearchService::class); + $fake->shouldReceive('search') + ->once() + ->withArgs(function ($s, string $q, array $filters = [], $session = null, bool $log = false) use ($store): bool { + return $s->getKey() === $store->getKey() && $q === 'match' && $log === false; + }) + ->andReturn(new \Illuminate\Database\Eloquent\Collection([$matching])); + + app()->instance(SearchService::class, $fake); + + $this->actingAs($user); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->set('search', 'match') + ->assertSee('Matching product') + ->assertDontSee('Other thing'); +}); + +it('falls back to paginated list for queries shorter than 2 chars', function () { + [$store, $user] = searchSetup(); + Product::factory()->create(['store_id' => $store->getKey(), 'title' => 'Alpha']); + Product::factory()->create(['store_id' => $store->getKey(), 'title' => 'Bravo']); + + $fake = Mockery::mock(SearchService::class); + $fake->shouldNotReceive('search'); + app()->instance(SearchService::class, $fake); + + $this->actingAs($user); + + Livewire::test(\App\Livewire\Admin\Products\Index::class) + ->set('search', 'a') + ->assertSee('Alpha') + ->assertSee('Bravo'); +}); diff --git a/tests/Feature/Admin/ProductsTest.php b/tests/Feature/Admin/ProductsTest.php new file mode 100644 index 00000000..0127ed27 --- /dev/null +++ b/tests/Feature/Admin/ProductsTest.php @@ -0,0 +1,61 @@ +create(); + $user = User::factory()->create(['email_verified_at' => now()]); + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role->value, + 'created_at' => now(), + ]); + session(['current_store_id' => $store->getKey()]); + + return [$store, $user]; +} + +it('lists products for owner', function () { + [$store, $user] = productsSetup(); + $product = Product::factory()->create(['store_id' => $store->getKey(), 'title' => 'Blue Shirt']); + ProductVariant::factory()->create(['product_id' => $product->getKey()]); + + $this->actingAs($user)->get('/admin/products') + ->assertOk() + ->assertSee('Blue Shirt'); +}); + +it('shows product edit for owner', function () { + [$store, $user] = productsSetup(); + $product = Product::factory()->create(['store_id' => $store->getKey(), 'title' => 'Red Hat']); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + InventoryItem::factory()->create(['store_id' => $store->getKey(), 'variant_id' => $variant->getKey()]); + + $this->actingAs($user)->get('/admin/products/'.$product->getKey().'/edit') + ->assertOk() + ->assertSee('Red Hat'); +}); + +it('denies support role from updating product', function () { + [$store, $user] = productsSetup(StoreUserRole::Support); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + + $response = $this->actingAs($user)->get('/admin/products/'.$product->getKey().'/edit') + ->assertOk(); + + Livewire\Livewire::test(\App\Livewire\Admin\Products\Edit::class, ['product' => $product->getKey()]) + ->set('title', 'Changed') + ->call('save') + ->assertForbidden(); +}); diff --git a/tests/Feature/Admin/StoreSwitcherTest.php b/tests/Feature/Admin/StoreSwitcherTest.php new file mode 100644 index 00000000..f73ec0ee --- /dev/null +++ b/tests/Feature/Admin/StoreSwitcherTest.php @@ -0,0 +1,46 @@ +create(['name' => 'Store A']); + $storeB = Store::factory()->create(['name' => 'Store B']); + + $user = User::factory()->create(['email_verified_at' => now()]); + + foreach ([$storeA, $storeB] as $store) { + DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + } + + Product::factory()->create(['store_id' => $storeA->getKey(), 'title' => 'Alpha Widget']); + Product::factory()->create(['store_id' => $storeB->getKey(), 'title' => 'Beta Gadget']); + + session(['current_store_id' => $storeA->getKey()]); + + $this->actingAs($user)->get('/admin/products') + ->assertOk() + ->assertSee('Alpha Widget') + ->assertDontSee('Beta Gadget'); + + $this->actingAs($user)->get('/admin/switch-store/'.$storeB->getKey()) + ->assertRedirect('/admin'); + + expect(session('current_store_id'))->toBe($storeB->getKey()); + + $this->actingAs($user)->get('/admin/products') + ->assertOk() + ->assertSee('Beta Gadget') + ->assertDontSee('Alpha Widget'); +}); diff --git a/tests/Feature/Analytics/AnalyticsServiceTest.php b/tests/Feature/Analytics/AnalyticsServiceTest.php new file mode 100644 index 00000000..e562febe --- /dev/null +++ b/tests/Feature/Analytics/AnalyticsServiceTest.php @@ -0,0 +1,69 @@ +create(); + + $event = app(AnalyticsService::class)->track( + $store, + AnalyticsEventType::PageView, + ['path' => '/hats'], + 'sess-1', + ); + + expect($event)->not->toBeNull() + ->and($event->type)->toBe(AnalyticsEventType::PageView->value) + ->and($event->properties_json)->toBe(['path' => '/hats']) + ->and($event->session_id)->toBe('sess-1') + ->and($event->store_id)->toBe($store->getKey()); +}); + +it('is idempotent for the same client_event_id per store', function () { + $store = Store::factory()->create(); + $service = app(AnalyticsService::class); + + $first = $service->track($store, AnalyticsEventType::AddToCart, ['variant_id' => 3], null, null, 'cid-1'); + $duplicate = $service->track($store, AnalyticsEventType::AddToCart, ['variant_id' => 3], null, null, 'cid-1'); + + expect($first)->not->toBeNull() + ->and($duplicate)->toBeNull(); + + $count = AnalyticsEvent::query()->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('client_event_id', 'cid-1') + ->count(); + + expect($count)->toBe(1); +}); + +it('allows the same client_event_id in different stores', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + $service = app(AnalyticsService::class); + + $a = $service->track($storeA, AnalyticsEventType::ProductView, [], null, null, 'shared-id'); + $b = $service->track($storeB, AnalyticsEventType::ProductView, [], null, null, 'shared-id'); + + expect($a)->not->toBeNull() + ->and($b)->not->toBeNull(); +}); + +it('scopes read queries through BelongsToStore', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + app(AnalyticsService::class)->track($storeA, AnalyticsEventType::PageView); + app(AnalyticsService::class)->track($storeB, AnalyticsEventType::PageView); + + app()->instance('current_store', $storeA); + + $count = AnalyticsEvent::query()->count(); + + expect($count)->toBe(1); +}); diff --git a/tests/Feature/Analytics/DashboardMetricsServiceTest.php b/tests/Feature/Analytics/DashboardMetricsServiceTest.php new file mode 100644 index 00000000..a49a67d7 --- /dev/null +++ b/tests/Feature/Analytics/DashboardMetricsServiceTest.php @@ -0,0 +1,45 @@ +create(); + + AnalyticsDaily::factory()->create([ + 'store_id' => $store->getKey(), + 'date' => '2026-04-10', + 'orders_count' => 3, + 'revenue_amount' => 15_000, + 'aov_amount' => 5_000, + 'visits_count' => 42, + ]); + + $metrics = app(DashboardMetricsService::class)->forDay($store, CarbonImmutable::parse('2026-04-10')); + + expect($metrics['orders_count'])->toBe(3) + ->and($metrics['revenue_amount'])->toBe(15_000) + ->and($metrics['visits_count'])->toBe(42); +}); + +it('falls back to live aggregation when no roll-up exists', function () { + $store = Store::factory()->create(); + $date = CarbonImmutable::parse('2026-04-12'); + + CarbonImmutable::setTestNow($date->setTime(14, 0)); + app(AnalyticsService::class)->track($store, AnalyticsEventType::PageView, [], 'sess-a'); + app(AnalyticsService::class)->track($store, AnalyticsEventType::PageView, [], 'sess-b'); + app(AnalyticsService::class)->track($store, AnalyticsEventType::AddToCart, [], 'sess-a'); + CarbonImmutable::setTestNow(); + + $metrics = app(DashboardMetricsService::class)->forDay($store, $date); + + expect($metrics['visits_count'])->toBe(2) + ->and($metrics['add_to_cart_count'])->toBe(1); +}); diff --git a/tests/Feature/Analytics/RollupAnalyticsDailyTest.php b/tests/Feature/Analytics/RollupAnalyticsDailyTest.php new file mode 100644 index 00000000..043c02bb --- /dev/null +++ b/tests/Feature/Analytics/RollupAnalyticsDailyTest.php @@ -0,0 +1,83 @@ +create(); + $yesterday = CarbonImmutable::yesterday(); + + $service = app(AnalyticsService::class); + + CarbonImmutable::setTestNow($yesterday->setTime(12, 0)); + + $service->track($store, AnalyticsEventType::PageView, [], 'sess-a'); + $service->track($store, AnalyticsEventType::PageView, [], 'sess-a'); + $service->track($store, AnalyticsEventType::PageView, [], 'sess-b'); + $service->track($store, AnalyticsEventType::AddToCart, ['variant_id' => 1], 'sess-a'); + $service->track($store, AnalyticsEventType::CheckoutStarted, [], 'sess-a'); + $service->track($store, AnalyticsEventType::CheckoutCompleted, [], 'sess-a'); + + CarbonImmutable::setTestNow(); + + $this->artisan('analytics:rollup')->assertExitCode(0); + + $row = AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', $yesterday->toDateString()) + ->first(); + + expect($row)->not->toBeNull() + ->and($row->visits_count)->toBe(2) + ->and($row->add_to_cart_count)->toBe(1) + ->and($row->checkout_started_count)->toBe(1) + ->and($row->checkout_completed_count)->toBe(1); +}); + +it('upserts the row when run twice', function () { + $store = Store::factory()->create(); + $yesterday = CarbonImmutable::yesterday(); + + CarbonImmutable::setTestNow($yesterday->setTime(10, 0)); + app(AnalyticsService::class)->track($store, AnalyticsEventType::PageView, [], 'sess-x'); + CarbonImmutable::setTestNow(); + + $this->artisan('analytics:rollup')->assertExitCode(0); + $this->artisan('analytics:rollup')->assertExitCode(0); + + $count = AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', $yesterday->toDateString()) + ->count(); + + expect($count)->toBe(1); +}); + +it('accepts an explicit --date option', function () { + $store = Store::factory()->create(); + $target = CarbonImmutable::parse('2026-01-05'); + + CarbonImmutable::setTestNow($target->setTime(8, 0)); + app(AnalyticsService::class)->track($store, AnalyticsEventType::PageView, [], 'sess-1'); + app(AnalyticsService::class)->track($store, AnalyticsEventType::AddToCart, [], 'sess-1'); + CarbonImmutable::setTestNow(); + + $this->artisan('analytics:rollup', ['--date' => '2026-01-05'])->assertExitCode(0); + + $row = AnalyticsDaily::query() + ->withoutGlobalScopes() + ->where('store_id', $store->getKey()) + ->where('date', '2026-01-05') + ->first(); + + expect($row)->not->toBeNull() + ->and($row->visits_count)->toBe(1) + ->and($row->add_to_cart_count)->toBe(1); +}); diff --git a/tests/Feature/Api/Admin/OrderApiTest.php b/tests/Feature/Api/Admin/OrderApiTest.php new file mode 100644 index 00000000..b7c4f7be --- /dev/null +++ b/tests/Feature/Api/Admin/OrderApiTest.php @@ -0,0 +1,27 @@ +create(); + $user = User::factory()->create(); + \DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + Order::factory()->count(2)->create(['store_id' => $store->getKey()]); + + Sanctum::actingAs($user, ['*']); + + $this->getJson("/api/admin/v1/stores/{$store->getKey()}/orders") + ->assertOk() + ->assertJsonCount(2, 'data'); +}); diff --git a/tests/Feature/Api/Admin/ProductApiTest.php b/tests/Feature/Api/Admin/ProductApiTest.php new file mode 100644 index 00000000..fe5cc16c --- /dev/null +++ b/tests/Feature/Api/Admin/ProductApiTest.php @@ -0,0 +1,92 @@ +create(); + $user = User::factory()->create(); + \DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Admin->value, + 'created_at' => now(), + ]); + + return [$store, $user]; +} + +it('requires authentication', function () { + $this->getJson('/api/admin/v1/stores/1/products')->assertUnauthorized(); +}); + +it('lists products for a store the authenticated user belongs to', function () { + [$store, $user] = seedAdminContext(); + Product::factory()->count(3)->create(['store_id' => $store->getKey()]); + + Sanctum::actingAs($user, ['*']); + + $this->getJson("/api/admin/v1/stores/{$store->getKey()}/products") + ->assertOk() + ->assertJsonCount(3, 'data'); +}); + +it('rejects access to stores the user does not belong to', function () { + [$store, $user] = seedAdminContext(); + $otherStore = Store::factory()->create(); + + Sanctum::actingAs($user, ['*']); + + $this->getJson("/api/admin/v1/stores/{$otherStore->getKey()}/products") + ->assertForbidden(); +}); + +it('creates a product with write-products ability', function () { + [$store, $user] = seedAdminContext(); + + Sanctum::actingAs($user, ['write-products']); + + $response = $this->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'API Tee', + ]); + + $response->assertCreated() + ->assertJsonPath('data.title', 'API Tee'); +}); + +it('rejects product creation when token lacks write-products ability', function () { + [$store, $user] = seedAdminContext(); + + Sanctum::actingAs($user, ['read-products']); + + $this->postJson("/api/admin/v1/stores/{$store->getKey()}/products", [ + 'title' => 'Denied', + ])->assertForbidden(); +}); + +it('validates required title on create', function () { + [$store, $user] = seedAdminContext(); + + Sanctum::actingAs($user, ['*']); + + $this->postJson("/api/admin/v1/stores/{$store->getKey()}/products", []) + ->assertStatus(422) + ->assertJsonValidationErrors(['title']); +}); + +it('updates a product', function () { + [$store, $user] = seedAdminContext(); + $product = Product::factory()->create(['store_id' => $store->getKey(), 'title' => 'Old']); + + Sanctum::actingAs($user, ['*']); + + $this->putJson("/api/admin/v1/stores/{$store->getKey()}/products/{$product->getKey()}", [ + 'title' => 'New', + ])->assertOk()->assertJsonPath('data.title', 'New'); +}); diff --git a/tests/Feature/Api/Admin/WebhookApiTest.php b/tests/Feature/Api/Admin/WebhookApiTest.php new file mode 100644 index 00000000..057b5795 --- /dev/null +++ b/tests/Feature/Api/Admin/WebhookApiTest.php @@ -0,0 +1,53 @@ +create(); + \DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => $role, + 'created_at' => now(), + ]); + + return $user; +} + +it('creates a webhook subscription via the API', function () { + $store = Store::factory()->create(); + $user = seedAdminUserForStore($store, StoreUserRole::Owner->value); + + Sanctum::actingAs($user, ['*']); + + $response = $this->postJson("/api/admin/v1/stores/{$store->getKey()}/webhook-subscriptions", [ + 'event_type' => 'order.paid', + 'target_url' => 'https://example.test/webhook', + 'signing_secret' => 'mysecret-1234', + ]); + + $response->assertCreated() + ->assertJsonPath('data.event_type', 'order.paid'); + + expect(WebhookSubscription::query()->where('store_id', $store->getKey())->count())->toBe(1); +}); + +it('validates event_type', function () { + $store = Store::factory()->create(); + $user = seedAdminUserForStore($store); + + Sanctum::actingAs($user, ['*']); + + $this->postJson("/api/admin/v1/stores/{$store->getKey()}/webhook-subscriptions", [ + 'event_type' => 'nonsense', + 'target_url' => 'https://example.test/x', + 'signing_secret' => 'shortsec', + ])->assertStatus(422)->assertJsonValidationErrors(['event_type']); +}); diff --git a/tests/Feature/Api/Storefront/CartApiTest.php b/tests/Feature/Api/Storefront/CartApiTest.php new file mode 100644 index 00000000..1e90706a --- /dev/null +++ b/tests/Feature/Api/Storefront/CartApiTest.php @@ -0,0 +1,92 @@ +create(); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +function seedProductWithVariant(Store $store, int $price = 1000, int $onHand = 10): ProductVariant +{ + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $price, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $onHand, + ]); + + return $variant; +} + +it('creates a cart via the API', function () { + seedApiStore(); + + $response = $this->postJson('http://shop.test/api/storefront/v1/carts'); + + $response->assertCreated() + ->assertJsonPath('data.currency', 'USD') + ->assertJsonPath('data.cart_version', 1); +}); + +it('adds a line to an existing cart', function () { + $store = seedApiStore(); + $variant = seedProductWithVariant($store); + + $create = $this->postJson('http://shop.test/api/storefront/v1/carts'); + $cartId = $create->json('data.id'); + + $response = $this->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'variant_id' => $variant->getKey(), + 'quantity' => 2, + ]); + + $response->assertOk() + ->assertJsonPath('data.cart_version', 2); + + expect(Cart::query()->findOrFail($cartId)->lines()->count())->toBe(1); +}); + +it('rejects missing variant_id with 422', function () { + seedApiStore(); + $create = $this->postJson('http://shop.test/api/storefront/v1/carts'); + $cartId = $create->json('data.id'); + + $response = $this->postJson("http://shop.test/api/storefront/v1/carts/{$cartId}/lines", [ + 'quantity' => 1, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['variant_id']); +}); + +it('returns 404 for a cart belonging to a different store', function () { + $storeA = seedApiStore('shop.test'); + $storeB = seedApiStore('other.test'); + + $cart = Cart::factory()->create(['store_id' => $storeB->getKey()]); + + $response = $this->getJson("http://shop.test/api/storefront/v1/carts/{$cart->getKey()}"); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Api/Storefront/CheckoutApiTest.php b/tests/Feature/Api/Storefront/CheckoutApiTest.php new file mode 100644 index 00000000..84858291 --- /dev/null +++ b/tests/Feature/Api/Storefront/CheckoutApiTest.php @@ -0,0 +1,143 @@ +create(); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1500, + 'requires_shipping' => 1, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), 1); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->getKey(), + 'config_json' => ['amount' => 500], + ]); + TaxSettings::factory()->create([ + 'store_id' => $store->getKey(), + 'config_json' => ['default_rate_bps' => 0], + ]); + + return [$store, $cart, $rate]; +} + +it('starts a checkout from an existing cart', function () { + [, $cart] = seedCheckoutFixture(); + + $response = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart->getKey(), + ]); + + $response->assertCreated() + ->assertJsonPath('data.status', 'started'); +}); + +it('walks through address + shipping + pay', function () { + [, $cart, $rate] = seedCheckoutFixture(); + + $checkoutId = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart->getKey(), + ])->json('data.id'); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/address", [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'A', + 'last_name' => 'B', + 'address1' => '1 Main', + 'city' => 'Somewhere', + 'country_code' => 'US', + 'postal_code' => '12345', + ], + ])->assertOk()->assertJsonPath('data.status', 'addressed'); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [ + 'shipping_rate_id' => $rate->getKey(), + ])->assertOk()->assertJsonPath('data.status', 'shipping_selected'); + + $payResponse = $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4242424242424242', + ]); + + $payResponse->assertCreated() + ->assertJsonPath('data.financial_status', 'paid') + ->assertJsonPath('data.status', 'paid'); +}); + +it('rejects checkout address validation errors', function () { + [, $cart] = seedCheckoutFixture(); + + $checkoutId = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart->getKey(), + ])->json('data.id'); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/address", [ + 'email' => 'not-an-email', + 'shipping_address' => [], + ])->assertStatus(422)->assertJsonValidationErrors([ + 'email', + 'shipping_address.first_name', + ]); +}); + +it('returns declined error when card is the decline magic number', function () { + [, $cart, $rate] = seedCheckoutFixture(); + + $checkoutId = $this->postJson('http://shop.test/api/storefront/v1/checkouts', [ + 'cart_id' => $cart->getKey(), + ])->json('data.id'); + + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/address", [ + 'email' => 'b@example.test', + 'shipping_address' => [ + 'first_name' => 'A', + 'last_name' => 'B', + 'address1' => '1 Main', + 'city' => 'Somewhere', + 'country_code' => 'US', + 'postal_code' => '12345', + ], + ]); + $this->putJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/shipping-method", [ + 'shipping_rate_id' => $rate->getKey(), + ]); + + $this->postJson("http://shop.test/api/storefront/v1/checkouts/{$checkoutId}/pay", [ + 'payment_method' => 'credit_card', + 'card_number' => '4000000000000002', + ])->assertStatus(422)->assertJson(['error' => 'card_declined']); +}); diff --git a/tests/Feature/Api/ValidationRulesTest.php b/tests/Feature/Api/ValidationRulesTest.php new file mode 100644 index 00000000..9cf2f2af --- /dev/null +++ b/tests/Feature/Api/ValidationRulesTest.php @@ -0,0 +1,83 @@ +rules(); + $passes = Validator::make($payload, $rules)->passes(); + + expect($passes)->toBe($expectValid); +})->with([ + 'missing variant_id' => [['quantity' => 1], false], + 'zero quantity' => [['variant_id' => 1, 'quantity' => 0], false], + 'negative quantity' => [['variant_id' => 1, 'quantity' => -1], false], + 'valid payload' => [['variant_id' => 42, 'quantity' => 2], true], +]); + +it('validates UpdateCartLineRequest quantity', function (array $payload, bool $expectValid): void { + $rules = (new UpdateCartLineRequest)->rules(); + $passes = Validator::make($payload, $rules)->passes(); + + expect($passes)->toBe($expectValid); +})->with([ + 'missing quantity' => [[], false], + 'negative quantity' => [['quantity' => -1], false], + 'zero quantity allowed for removal' => [['quantity' => 0], true], + 'valid quantity' => [['quantity' => 3], true], +]); + +it('validates SetCheckoutAddressRequest', function (array $payload, bool $expectValid): void { + $rules = (new SetCheckoutAddressRequest)->rules(); + $passes = Validator::make($payload, $rules)->passes(); + + expect($passes)->toBe($expectValid); +})->with([ + 'missing email' => [[ + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '1 Main', + 'city' => 'LA', + 'country_code' => 'US', + 'postal_code' => '90001', + ], + ], false], + 'invalid country code length' => [[ + 'email' => 'a@b.com', + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '1 Main', + 'city' => 'LA', + 'country_code' => 'USA', + 'postal_code' => '90001', + ], + ], false], + 'valid address' => [[ + 'email' => 'buyer@example.com', + 'shipping_address' => [ + 'first_name' => 'Jane', + 'last_name' => 'Doe', + 'address1' => '1 Main', + 'city' => 'LA', + 'country_code' => 'US', + 'postal_code' => '90001', + ], + ], true], +]); + +it('validates StoreProductRequest', function (array $payload, bool $expectValid): void { + $rules = (new StoreProductRequest)->rules(); + $passes = Validator::make($payload, $rules)->passes(); + + expect($passes)->toBe($expectValid); +})->with([ + 'missing title' => [['status' => 'active'], false], + 'invalid status' => [['title' => 'Test', 'status' => 'unknown'], false], + 'tag too long' => [['title' => 'Test', 'tags' => [str_repeat('x', 65)]], false], + 'valid payload' => [['title' => 'Test', 'status' => 'active', 'tags' => ['a', 'b']], true], +]); diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index fff11fd7..6863270b 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -66,4 +66,4 @@ $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 index 66f58e36..c8ea4ecf 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -66,4 +66,4 @@ 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 index f42a259e..997196f9 100644 --- a/tests/Feature/Auth/PasswordConfirmationTest.php +++ b/tests/Feature/Auth/PasswordConfirmationTest.php @@ -10,4 +10,4 @@ $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 index bea78251..99721180 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -58,4 +58,4 @@ return true; }); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index c22ea5e1..144036c7 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -20,4 +20,4 @@ ->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 index cda794f2..a2ce0cd5 100644 --- a/tests/Feature/Auth/TwoFactorChallengeTest.php +++ b/tests/Feature/Auth/TwoFactorChallengeTest.php @@ -31,4 +31,4 @@ 'email' => $user->email, 'password' => 'password', ])->assertRedirect(route('two-factor.login')); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Catalog/HandleGeneratorTest.php b/tests/Feature/Catalog/HandleGeneratorTest.php new file mode 100644 index 00000000..63ae5b48 --- /dev/null +++ b/tests/Feature/Catalog/HandleGeneratorTest.php @@ -0,0 +1,52 @@ +create(); + + $handle = HandleGenerator::unique(Product::class, (int) $store->getKey(), 'My Product Name'); + + expect($handle)->toBe('my-product-name'); +}); + +it('appends incrementing suffixes when base collides', function () { + $store = Store::factory()->create(); + + Product::factory()->create(['store_id' => $store->getKey(), 'handle' => 'shirt']); + Product::factory()->create(['store_id' => $store->getKey(), 'handle' => 'shirt-2']); + + $handle = HandleGenerator::unique(Product::class, (int) $store->getKey(), 'Shirt'); + + expect($handle)->toBe('shirt-3'); +}); + +it('scopes uniqueness per store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + Product::factory()->create(['store_id' => $storeA->getKey(), 'handle' => 'same']); + + $handle = HandleGenerator::unique(Product::class, (int) $storeB->getKey(), 'Same'); + + expect($handle)->toBe('same'); +}); + +it('ignores the current record when regenerating', function () { + $store = Store::factory()->create(); + + $product = Product::factory()->create(['store_id' => $store->getKey(), 'handle' => 'hoodie']); + + $handle = HandleGenerator::unique( + Product::class, + (int) $store->getKey(), + 'hoodie', + ignoreId: (int) $product->getKey(), + ); + + expect($handle)->toBe('hoodie'); +}); diff --git a/tests/Feature/Catalog/InventoryServiceTest.php b/tests/Feature/Catalog/InventoryServiceTest.php new file mode 100644 index 00000000..989a2718 --- /dev/null +++ b/tests/Feature/Catalog/InventoryServiceTest.php @@ -0,0 +1,94 @@ +create(); + $product = Product::factory()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $onHand, + 'quantity_reserved' => 0, + 'policy' => $policy->value, + ]); + + return $variant; +} + +it('reserves inventory and bumps the reserved count', function () { + $variant = makeVariantWithInventory(10); + + $item = app(InventoryService::class)->reserve($variant, 3); + + expect($item->quantity_on_hand)->toBe(10) + ->and($item->quantity_reserved)->toBe(3); +}); + +it('throws when deny policy is short on stock', function () { + $variant = makeVariantWithInventory(2); + + app(InventoryService::class)->reserve($variant, 5); +})->throws(InsufficientInventoryException::class); + +it('allows reserving negative available inventory under continue policy', function () { + $variant = makeVariantWithInventory(1, InventoryPolicy::Continue); + + $item = app(InventoryService::class)->reserve($variant, 5); + + expect($item->quantity_reserved)->toBe(5) + ->and($item->available())->toBe(-4); +}); + +it('releases reserved inventory without going below zero', function () { + $variant = makeVariantWithInventory(10); + $service = app(InventoryService::class); + + $service->reserve($variant, 4); + $item = $service->release($variant, 2); + + expect($item->quantity_reserved)->toBe(2); + + $item = $service->release($variant, 100); + expect($item->quantity_reserved)->toBe(0); +}); + +it('commits reserved inventory and decrements on hand', function () { + $variant = makeVariantWithInventory(10); + $service = app(InventoryService::class); + + $service->reserve($variant, 3); + $item = $service->commit($variant, 3); + + expect($item->quantity_on_hand)->toBe(7) + ->and($item->quantity_reserved)->toBe(0); +}); + +it('restocks on hand on refund', function () { + $variant = makeVariantWithInventory(5); + + $item = app(InventoryService::class)->restock($variant, 4); + + expect($item->quantity_on_hand)->toBe(9); +}); + +it('reports availability for deny and continue policies', function () { + $deny = makeVariantWithInventory(2); + $continue = makeVariantWithInventory(0, InventoryPolicy::Continue); + $service = app(InventoryService::class); + + expect($service->checkAvailability($deny, 2))->toBeTrue() + ->and($service->checkAvailability($deny, 3))->toBeFalse() + ->and($service->checkAvailability($continue, 9999))->toBeTrue(); +}); diff --git a/tests/Feature/Catalog/ProductServiceTest.php b/tests/Feature/Catalog/ProductServiceTest.php new file mode 100644 index 00000000..060963e3 --- /dev/null +++ b/tests/Feature/Catalog/ProductServiceTest.php @@ -0,0 +1,125 @@ +create(); +} + +it('creates a product with default variant and inventory item', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), [ + 'title' => 'Awesome Tee', + 'price_amount' => 2500, + 'quantity_on_hand' => 10, + ]); + + expect($product->title)->toBe('Awesome Tee') + ->and($product->handle)->toBe('awesome-tee') + ->and($product->status)->toBe(ProductStatus::Draft) + ->and($product->variants()->count())->toBe(1); + + $variant = $product->variants()->first(); + + expect($variant->is_default)->toBeTrue() + ->and($variant->price_amount)->toBe(2500) + ->and($variant->inventoryItem)->not->toBeNull() + ->and($variant->inventoryItem->quantity_on_hand)->toBe(10); +}); + +it('enforces handle uniqueness per store by appending suffix', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $first = $service->create((int) $store->getKey(), ['title' => 'Same Name', 'price_amount' => 100]); + $second = $service->create((int) $store->getKey(), ['title' => 'Same Name', 'price_amount' => 100]); + + expect($first->handle)->toBe('same-name') + ->and($second->handle)->toBe('same-name-2'); +}); + +it('transitions draft to active when variant has price and title present', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Buyable', 'price_amount' => 999]); + + $updated = $service->transitionStatus($product, ProductStatus::Active); + + expect($updated->status)->toBe(ProductStatus::Active) + ->and($updated->published_at)->not->toBeNull(); +}); + +it('refuses to activate a product without a priced variant', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Free', 'price_amount' => 0]); + + $service->transitionStatus($product, ProductStatus::Active); +})->throws(InvalidProductTransitionException::class); + +it('refuses to revert active back to draft when stock is ok but no order references is allowed', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Reversible', 'price_amount' => 300]); + $service->transitionStatus($product, ProductStatus::Active); + + $reverted = $service->transitionStatus($product->fresh(), ProductStatus::Draft); + + expect($reverted->status)->toBe(ProductStatus::Draft); +}); + +it('is idempotent when transitioning to the current status', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Stays', 'price_amount' => 100]); + + $same = $service->transitionStatus($product, ProductStatus::Draft); + + expect($same->status)->toBe(ProductStatus::Draft); +}); + +it('deletes only draft products', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Erasable', 'price_amount' => 100]); + + $service->delete($product); + + expect(Product::query()->where('id', $product->getKey())->exists())->toBeFalse(); +}); + +it('refuses to delete an active product', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Active One', 'price_amount' => 500]); + $service->transitionStatus($product, ProductStatus::Active); + + $service->delete($product); +})->throws(RuntimeException::class); + +it('updates a product and keeps the variant intact', function () { + $store = makeStore(); + $service = app(ProductService::class); + + $product = $service->create((int) $store->getKey(), ['title' => 'Original', 'price_amount' => 100]); + + $updated = $service->update($product, ['title' => 'Renamed']); + + expect($updated->title)->toBe('Renamed') + ->and($updated->variants()->count())->toBe(1); +}); diff --git a/tests/Feature/Catalog/VariantMatrixServiceTest.php b/tests/Feature/Catalog/VariantMatrixServiceTest.php new file mode 100644 index 00000000..8c1fcdfb --- /dev/null +++ b/tests/Feature/Catalog/VariantMatrixServiceTest.php @@ -0,0 +1,111 @@ +create(); + $product = app(ProductService::class)->create((int) $store->getKey(), [ + 'title' => 'Matrix Tee', + 'price_amount' => 1500, + ]); + + $sizeOption = ProductOption::query()->create([ + 'product_id' => $product->getKey(), + 'name' => 'Size', + 'position' => 0, + ]); + + $small = ProductOptionValue::query()->create([ + 'product_option_id' => $sizeOption->getKey(), + 'value' => 'Small', + 'position' => 0, + ]); + $medium = ProductOptionValue::query()->create([ + 'product_option_id' => $sizeOption->getKey(), + 'value' => 'Medium', + 'position' => 1, + ]); + + $colorOption = ProductOption::query()->create([ + 'product_id' => $product->getKey(), + 'name' => 'Color', + 'position' => 1, + ]); + + $red = ProductOptionValue::query()->create([ + 'product_option_id' => $colorOption->getKey(), + 'value' => 'Red', + 'position' => 0, + ]); + $blue = ProductOptionValue::query()->create([ + 'product_option_id' => $colorOption->getKey(), + 'value' => 'Blue', + 'position' => 1, + ]); + + app(VariantMatrixService::class)->rebuildMatrix($product->fresh()); + + $product = $product->fresh(); + $product->load('variants.optionValues'); + + $matrixVariants = $product->variants()->whereHas('optionValues')->get(); + + expect($matrixVariants)->toHaveCount(4); + + $sets = $matrixVariants->map(function ($variant) { + return $variant->optionValues->pluck('id')->sort()->values()->all(); + })->values(); + + $expected = [ + [$small->getKey(), $red->getKey()], + [$small->getKey(), $blue->getKey()], + [$medium->getKey(), $red->getKey()], + [$medium->getKey(), $blue->getKey()], + ]; + + foreach ($expected as $combo) { + sort($combo); + expect($sets->contains(fn ($actual) => $actual === $combo))->toBeTrue(); + } +}); + +it('deletes orphan variants without order references', function () { + $store = Store::factory()->create(); + $service = app(VariantMatrixService::class); + $product = app(ProductService::class)->create((int) $store->getKey(), [ + 'title' => 'Shrinking', + 'price_amount' => 500, + ]); + + $sizeOption = ProductOption::query()->create([ + 'product_id' => $product->getKey(), + 'name' => 'Size', + 'position' => 0, + ]); + + $small = ProductOptionValue::query()->create([ + 'product_option_id' => $sizeOption->getKey(), + 'value' => 'S', + 'position' => 0, + ]); + $medium = ProductOptionValue::query()->create([ + 'product_option_id' => $sizeOption->getKey(), + 'value' => 'M', + 'position' => 1, + ]); + + $service->rebuildMatrix($product->fresh()); + expect($product->fresh()->variants()->whereHas('optionValues')->count())->toBe(2); + + $medium->delete(); + + $service->rebuildMatrix($product->fresh()); + + expect($product->fresh()->variants()->whereHas('optionValues')->count())->toBe(1); +}); diff --git a/tests/Feature/Commerce/CartServiceTest.php b/tests/Feature/Commerce/CartServiceTest.php new file mode 100644 index 00000000..94f4d3d1 --- /dev/null +++ b/tests/Feature/Commerce/CartServiceTest.php @@ -0,0 +1,159 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $price, + 'requires_shipping' => 1, + 'weight_g' => 500, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $onHand, + ]); + + return [$store, $product, $variant]; +} + +it('creates a cart with store currency and version 1', function () { + [$store] = makeCartContext(); + + $cart = app(CartService::class)->create($store); + + expect($cart->currency)->toBe($store->default_currency) + ->and($cart->cart_version)->toBe(1) + ->and($cart->status->value)->toBe('active'); +}); + +it('adds a line and increments cart version', function () { + [$store, , $variant] = makeCartContext(10, 1500); + $service = app(CartService::class); + $cart = $service->create($store); + + $line = $service->addLine($cart, (int) $variant->getKey(), 2); + $cart->refresh(); + + expect($line->quantity)->toBe(2) + ->and($line->unit_price_amount)->toBe(1500) + ->and($line->line_subtotal_amount)->toBe(3000) + ->and($cart->cart_version)->toBe(2); +}); + +it('merges duplicate variants into the same cart line', function () { + [$store, , $variant] = makeCartContext(); + $service = app(CartService::class); + $cart = $service->create($store); + + $service->addLine($cart, (int) $variant->getKey(), 1); + $service->addLine($cart, (int) $variant->getKey(), 2); + + expect($cart->lines()->count())->toBe(1) + ->and($cart->lines()->first()->quantity)->toBe(3); +}); + +it('rejects adding a non-active product', function () { + $store = Store::factory()->create(); + $product = Product::factory()->create([ + 'store_id' => $store->getKey(), + 'status' => ProductStatus::Draft->value, + ]); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $cart = app(CartService::class)->create($store); + + app(CartService::class)->addLine($cart, (int) $variant->getKey(), 1); +})->throws(RuntimeException::class, 'Product is not active'); + +it('rejects adding when inventory is insufficient', function () { + [$store, , $variant] = makeCartContext(onHand: 1); + + $cart = app(CartService::class)->create($store); + + app(CartService::class)->addLine($cart, (int) $variant->getKey(), 5); +})->throws(InsufficientInventoryException::class); + +it('rejects adding when variant is archived', function () { + $store = Store::factory()->create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'status' => VariantStatus::Archived->value, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), 1); +})->throws(RuntimeException::class, 'Variant is not active'); + +it('updates line quantity and recalculates amounts', function () { + [$store, , $variant] = makeCartContext(10, 800); + $service = app(CartService::class); + $cart = $service->create($store); + $line = $service->addLine($cart, (int) $variant->getKey(), 1); + + $updated = $service->updateLineQuantity($cart, (int) $line->getKey(), 3); + + expect($updated->quantity)->toBe(3) + ->and($updated->line_subtotal_amount)->toBe(2400); +}); + +it('removes the line when quantity updates to zero', function () { + [$store, , $variant] = makeCartContext(); + $service = app(CartService::class); + $cart = $service->create($store); + $line = $service->addLine($cart, (int) $variant->getKey(), 2); + + $result = $service->updateLineQuantity($cart, (int) $line->getKey(), 0); + + expect($result)->toBeNull() + ->and($cart->lines()->count())->toBe(0); +}); + +it('throws a cart version conflict when expected version does not match', function () { + [$store, , $variant] = makeCartContext(); + $service = app(CartService::class); + $cart = $service->create($store); + + $service->addLine($cart, (int) $variant->getKey(), 1, expectedVersion: 99); +})->throws(CartVersionConflictException::class); + +it('merges a guest cart into a customer cart taking max quantity', function () { + [$store, , $variant] = makeCartContext(20, 500); + $service = app(CartService::class); + $guest = $service->create($store); + $customer = $service->create($store); + + $service->addLine($guest, (int) $variant->getKey(), 2); + $service->addLine($customer, (int) $variant->getKey(), 1); + + $merged = $service->mergeOnLogin($guest, $customer); + + expect($merged->lines()->count())->toBe(1) + ->and($merged->lines()->first()->quantity)->toBe(2) + ->and($guest->refresh()->status->value)->toBe('abandoned'); +}); diff --git a/tests/Feature/Commerce/CartShowTest.php b/tests/Feature/Commerce/CartShowTest.php new file mode 100644 index 00000000..717e9958 --- /dev/null +++ b/tests/Feature/Commerce/CartShowTest.php @@ -0,0 +1,62 @@ +assertStatus(200) + ->assertSee('Your cart is empty'); +}); + +it('updates line quantity through the Livewire component', function () { + $store = Store::factory()->create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1000, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 50, + ]); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, (int) $variant->getKey(), 1); + session(['cart_id' => $cart->getKey()]); + + Livewire::test(CartShow::class) + ->call('updateQuantity', $line->getKey(), 4) + ->assertStatus(200); + + expect($line->fresh()->quantity)->toBe(4); +}); + +it('removes a line through the Livewire component', function () { + $store = Store::factory()->create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create(['product_id' => $product->getKey()]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $cart = app(CartService::class)->create($store); + $line = app(CartService::class)->addLine($cart, (int) $variant->getKey(), 2); + session(['cart_id' => $cart->getKey()]); + + Livewire::test(CartShow::class) + ->call('removeLine', $line->getKey()) + ->assertStatus(200); + + expect($cart->lines()->count())->toBe(0); +}); diff --git a/tests/Feature/Commerce/CheckoutServiceTest.php b/tests/Feature/Commerce/CheckoutServiceTest.php new file mode 100644 index 00000000..456e9009 --- /dev/null +++ b/tests/Feature/Commerce/CheckoutServiceTest.php @@ -0,0 +1,142 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1500, + 'requires_shipping' => 1, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 100, + ]); + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), 1); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->getKey(), + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 800], + ]); + + TaxSettings::factory()->create([ + 'store_id' => $store->getKey(), + 'config_json' => ['default_rate_bps' => 0], + ]); + + return [$store, $cart, $variant, $rate]; +} + +it('starts a checkout in the started state and snapshots totals', function () { + [$store, $cart] = bootCheckoutFixture(); + + $checkout = app(CheckoutService::class)->start($store, $cart); + + expect($checkout->status)->toBe(CheckoutStatus::Started) + ->and($checkout->totals_json['subtotal'])->toBe(1500); +}); + +it('transitions through address, shipping, payment', function () { + [$store, $cart, , $rate] = bootCheckoutFixture(); + $service = app(CheckoutService::class); + + $checkout = $service->start($store, $cart); + $checkout = $service->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'Sam', + 'last_name' => 'Shopper', + 'address1' => '1 Main St', + 'city' => 'Somewhere', + 'country_code' => 'US', + 'postal_code' => '12345', + ], + ]); + + expect($checkout->status)->toBe(CheckoutStatus::Addressed); + + $checkout = $service->setShippingMethod($checkout, (int) $rate->getKey()); + + expect($checkout->status)->toBe(CheckoutStatus::ShippingSelected) + ->and($checkout->totals_json['shipping'])->toBe(800); + + $checkout = $service->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + expect($checkout->status)->toBe(CheckoutStatus::PaymentSelected) + ->and($checkout->payment_method)->toBe(PaymentMethod::CreditCard); +}); + +it('rejects shipping rates that do not belong to a matching zone', function () { + [$store, $cart] = bootCheckoutFixture(); + $service = app(CheckoutService::class); + $checkout = $service->start($store, $cart); + $service->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'A', + 'last_name' => 'B', + 'address1' => '1 St', + 'city' => 'X', + 'country_code' => 'CA', + 'postal_code' => 'K1A', + ], + ]); + + $otherZone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['DE'], + ]); + $otherRate = ShippingRate::factory()->create(['zone_id' => $otherZone->getKey()]); + + $service->setShippingMethod($checkout->refresh(), (int) $otherRate->getKey()); +})->throws(InvalidCheckoutStateException::class); + +it('applies a valid discount code to the checkout', function () { + [$store, $cart] = bootCheckoutFixture(); + $service = app(CheckoutService::class); + $checkout = $service->start($store, $cart); + + Discount::factory()->percent(20)->create([ + 'store_id' => $store->getKey(), + 'code' => 'TWENTY', + ]); + + $checkout = $service->applyDiscount($checkout, 'twenty'); + + expect($checkout->discount_code)->toBe('TWENTY') + ->and($checkout->totals_json['discount'])->toBe(300); +}); + +it('rejects an invalid discount code', function () { + [$store, $cart] = bootCheckoutFixture(); + $service = app(CheckoutService::class); + $checkout = $service->start($store, $cart); + + $service->applyDiscount($checkout, 'NOPE'); +})->throws(InvalidDiscountException::class); diff --git a/tests/Feature/Commerce/DiscountServiceTest.php b/tests/Feature/Commerce/DiscountServiceTest.php new file mode 100644 index 00000000..4855c1ad --- /dev/null +++ b/tests/Feature/Commerce/DiscountServiceTest.php @@ -0,0 +1,164 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $price, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 100, + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), $qty); + + return [$store, $cart, $product, $variant]; +} + +it('validates an active code case-insensitively', function () { + [$store, $cart] = makeCartWithLine(); + + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SAVE10', + ]); + + $result = app(DiscountService::class)->validate('save10', $store, $cart); + + expect($result->code)->toBe('SAVE10'); +}); + +it('rejects an unknown code', function () { + [$store, $cart] = makeCartWithLine(); + + app(DiscountService::class)->validate('NOPE', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_NOT_FOUND); + +it('rejects an expired discount by ends_at', function () { + [$store, $cart] = makeCartWithLine(); + + Discount::factory()->expired()->create([ + 'store_id' => $store->getKey(), + 'code' => 'OLD', + ]); + + app(DiscountService::class)->validate('OLD', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_EXPIRED); + +it('rejects a discount before starts_at', function () { + [$store, $cart] = makeCartWithLine(); + + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'FUTURE', + 'starts_at' => now()->addDay(), + ]); + + app(DiscountService::class)->validate('FUTURE', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_NOT_YET_ACTIVE); + +it('rejects a discount at its usage limit', function () { + [$store, $cart] = makeCartWithLine(); + + Discount::factory()->exhausted()->create([ + 'store_id' => $store->getKey(), + 'code' => 'DONE', + ]); + + app(DiscountService::class)->validate('DONE', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_USAGE_LIMIT_REACHED); + +it('rejects a discount below the minimum purchase threshold', function () { + [$store, $cart] = makeCartWithLine(price: 500, qty: 1); + + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'BIG', + 'rules_json' => ['min_purchase_amount' => 10000], + ]); + + app(DiscountService::class)->validate('BIG', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_MIN_PURCHASE_NOT_MET); + +it('rejects a discount that restricts to other products', function () { + [$store, $cart] = makeCartWithLine(); + $other = Product::factory()->active()->create(['store_id' => $store->getKey()]); + + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'LIMITED', + 'rules_json' => ['applicable_product_ids' => [(int) $other->getKey()]], + ]); + + app(DiscountService::class)->validate('LIMITED', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_NOT_APPLICABLE); + +it('rejects a disabled discount as expired', function () { + [$store, $cart] = makeCartWithLine(); + + Discount::factory()->create([ + 'store_id' => $store->getKey(), + 'code' => 'OFF', + 'status' => DiscountStatus::Disabled->value, + ]); + + app(DiscountService::class)->validate('OFF', $store, $cart); +})->throws(InvalidDiscountException::class, InvalidDiscountException::CODE_EXPIRED); + +it('calculates a percent discount allocated across qualifying lines', function () { + [$store, $cart] = makeCartWithLine(price: 1000, qty: 3); + + $discount = Discount::factory()->percent(10)->create([ + 'store_id' => $store->getKey(), + 'code' => 'P10', + ]); + + $result = app(DiscountService::class)->calculate($discount, $cart); + + expect($result->totalAmount)->toBe(300) + ->and($result->allocations)->toHaveCount(1); +}); + +it('caps a fixed discount at the qualifying subtotal', function () { + [$store, $cart] = makeCartWithLine(price: 500, qty: 1); + + $discount = Discount::factory()->fixed(999999)->create([ + 'store_id' => $store->getKey(), + 'code' => 'BIG', + ]); + + $result = app(DiscountService::class)->calculate($discount, $cart); + + expect($result->totalAmount)->toBe(500); +}); + +it('marks free shipping discounts with freeShipping flag', function () { + [$store, $cart] = makeCartWithLine(); + + $discount = Discount::factory()->freeShipping()->create([ + 'store_id' => $store->getKey(), + 'code' => 'SHIPFREE', + ]); + + $result = app(DiscountService::class)->calculate($discount, $cart); + + expect($result->freeShipping)->toBeTrue() + ->and($result->totalAmount)->toBe(0); +}); diff --git a/tests/Feature/Commerce/PricingEngineTest.php b/tests/Feature/Commerce/PricingEngineTest.php new file mode 100644 index 00000000..59a29ca1 --- /dev/null +++ b/tests/Feature/Commerce/PricingEngineTest.php @@ -0,0 +1,127 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $price, + 'requires_shipping' => 1, + 'weight_g' => 500, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 100, + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), $qty); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->getKey(), + 'type' => ShippingRateType::Flat->value, + 'config_json' => ['amount' => 500], + ]); + + TaxSettings::factory()->create([ + 'store_id' => $store->getKey(), + 'mode' => TaxMode::Manual->value, + 'provider' => TaxProviderType::None->value, + 'prices_include_tax' => $pricesInclude ? 1 : 0, + 'config_json' => ['default_rate_bps' => $taxRateBps], + ]); + + $discountCode = null; + + if (! empty($discount)) { + $discountCode = $discount['code'] ?? 'CODE'; + Discount::factory()->create(array_merge([ + 'store_id' => $store->getKey(), + 'code' => $discountCode, + ], $discount)); + } + + $checkout = Checkout::factory()->create([ + 'store_id' => $store->getKey(), + 'cart_id' => $cart->getKey(), + 'status' => CheckoutStatus::ShippingSelected->value, + 'shipping_address_json' => ['country_code' => 'US'], + 'shipping_method_id' => $rate->getKey(), + 'discount_code' => $discountCode, + ]); + + return [$store, $cart, $checkout, $rate]; +} + +it('produces subtotal + shipping + tax breakdown', function () { + [, , $checkout] = buildPricingFixture(price: 1000, qty: 2, taxRateBps: 1000); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(2000) + ->and($result->shipping)->toBe(500) + ->and($result->taxTotal)->toBe(200) + ->and($result->total)->toBe(2700); +}); + +it('applies a percent discount before tax', function () { + [, , $checkout] = buildPricingFixture( + price: 1000, + qty: 2, + taxRateBps: 1000, + discount: [ + 'code' => 'SAVE10', + 'value_type' => \App\Enums\DiscountValueType::Percent->value, + 'value_amount' => 10, + ], + ); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->subtotal)->toBe(2000) + ->and($result->discount)->toBe(200) + ->and($result->taxTotal)->toBe(180) + ->and($result->total)->toBe(2480); +}); + +it('applies free shipping discount by zeroing shipping', function () { + [, , $checkout] = buildPricingFixture( + price: 1000, + qty: 1, + taxRateBps: 0, + discount: [ + 'code' => 'FREESHIP', + 'value_type' => \App\Enums\DiscountValueType::FreeShipping->value, + 'value_amount' => 0, + ], + ); + + $result = app(PricingEngine::class)->calculate($checkout); + + expect($result->shipping)->toBe(0) + ->and($result->total)->toBe(1000); +}); diff --git a/tests/Feature/Commerce/ShippingCalculatorTest.php b/tests/Feature/Commerce/ShippingCalculatorTest.php new file mode 100644 index 00000000..224f054e --- /dev/null +++ b/tests/Feature/Commerce/ShippingCalculatorTest.php @@ -0,0 +1,169 @@ +create(); + ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, [ + 'country_code' => 'US', + ]); + + expect($zone)->not->toBeNull(); +}); + +it('prefers a region-specific zone over country-only when both match', function () { + $store = Store::factory()->create(); + $countryZone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + $regionZone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + 'regions_json' => ['US-CA'], + ]); + + $zone = app(ShippingCalculator::class)->getMatchingZone($store, [ + 'country_code' => 'US', + 'province_code' => 'CA', + ]); + + expect($zone->getKey())->toBe($regionZone->getKey()); +}); + +it('returns null when no zone matches', function () { + $store = Store::factory()->create(); + + expect(app(ShippingCalculator::class)->getMatchingZone($store, ['country_code' => 'ZZ']))->toBeNull(); +}); + +it('lists active rates for the matching zone', function () { + $store = Store::factory()->create(); + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + ShippingRate::factory()->create(['zone_id' => $zone->getKey(), 'is_active' => 1]); + ShippingRate::factory()->create(['zone_id' => $zone->getKey(), 'is_active' => 0]); + + $rates = app(ShippingCalculator::class)->getAvailableRates($store, ['country_code' => 'US']); + + expect($rates)->toHaveCount(1); +}); + +it('calculates a flat rate amount', function () { + [$cart, $rate] = makeShippingFixture(ShippingRateType::Flat, ['amount' => 799]); + + $amount = app(ShippingCalculator::class)->calculate($rate, $cart); + + expect($amount)->toBe(799); +}); + +it('calculates a weight-based rate using line weights', function () { + [$cart, $rate] = makeShippingFixture(ShippingRateType::Weight, [ + 'ranges' => [ + ['min_g' => 0, 'max_g' => 1000, 'amount' => 500], + ['min_g' => 1001, 'max_g' => 5000, 'amount' => 1000], + ], + ], weight: 2000, quantity: 1); + + $amount = app(ShippingCalculator::class)->calculate($rate, $cart); + + expect($amount)->toBe(1000); +}); + +it('calculates a price-based rate using cart subtotal', function () { + [$cart, $rate] = makeShippingFixture(ShippingRateType::Price, [ + 'ranges' => [ + ['min_amount' => 0, 'max_amount' => 4999, 'amount' => 799], + ['min_amount' => 5000, 'amount' => 0], + ], + ], price: 6000, quantity: 1); + + $amount = app(ShippingCalculator::class)->calculate($rate, $cart); + + expect($amount)->toBe(0); +}); + +it('returns zero shipping for all-digital carts', function () { + $store = Store::factory()->create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'requires_shipping' => 0, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $cart = Cart::factory()->create(['store_id' => $store->getKey()]); + CartLine::factory()->create([ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'unit_price_amount' => 1000, + 'quantity' => 1, + 'line_subtotal_amount' => 1000, + 'line_total_amount' => 1000, + ]); + + $zone = ShippingZone::factory()->create(['store_id' => $store->getKey()]); + $rate = ShippingRate::factory()->create(['zone_id' => $zone->getKey()]); + + $amount = app(ShippingCalculator::class)->calculate($rate, $cart); + + expect($amount)->toBe(0); +}); + +function makeShippingFixture(ShippingRateType $type, array $config, int $weight = 500, int $price = 1000, int $quantity = 1): array +{ + $store = Store::factory()->create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $price, + 'weight_g' => $weight, + 'requires_shipping' => 1, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 100, + ]); + + $cart = Cart::factory()->create(['store_id' => $store->getKey()]); + CartLine::factory()->create([ + 'cart_id' => $cart->getKey(), + 'variant_id' => $variant->getKey(), + 'unit_price_amount' => $price, + 'quantity' => $quantity, + 'line_subtotal_amount' => $price * $quantity, + 'line_total_amount' => $price * $quantity, + ]); + + $zone = ShippingZone::factory()->create(['store_id' => $store->getKey()]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->getKey(), + 'type' => $type->value, + 'config_json' => $config, + ]); + + return [$cart, $rate]; +} diff --git a/tests/Feature/Commerce/TaxCalculatorTest.php b/tests/Feature/Commerce/TaxCalculatorTest.php new file mode 100644 index 00000000..3f6c0d5e --- /dev/null +++ b/tests/Feature/Commerce/TaxCalculatorTest.php @@ -0,0 +1,104 @@ +create(); + + return TaxSettings::factory()->create([ + 'store_id' => $store->getKey(), + 'mode' => $mode->value, + 'provider' => TaxProviderType::None->value, + 'prices_include_tax' => $pricesInclude ? 1 : 0, + 'config_json' => $config, + ]); +} + +it('adds tax exclusively on net amounts using country rate', function () { + $settings = makeTaxSettings(['country_rates' => ['US' => 800]]); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1000, 'label' => 'Line']], + $settings, + ['country_code' => 'US'], + ); + + expect($result['total'])->toBe(80) + ->and($result['lines'])->toHaveCount(1); +}); + +it('extracts tax from gross amounts when prices include tax', function () { + $settings = makeTaxSettings(['default_rate_bps' => 1900], pricesInclude: true); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1190]], + $settings, + ['country_code' => 'DE'], + ); + + expect($result['total'])->toBe(190); +}); + +it('falls back to default rate when country not in mapping', function () { + $settings = makeTaxSettings([ + 'country_rates' => ['CA' => 500], + 'default_rate_bps' => 700, + ]); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1000]], + $settings, + ['country_code' => 'US'], + ); + + expect($result['total'])->toBe(70); +}); + +it('returns zero tax when no rate is configured', function () { + $settings = makeTaxSettings(); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1000]], + $settings, + ['country_code' => 'US'], + ); + + expect($result['total'])->toBe(0) + ->and($result['lines'])->toBe([]); +}); + +it('skips manual calculation when mode is provider', function () { + $settings = makeTaxSettings(['default_rate_bps' => 1000], mode: TaxMode::Provider); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1000]], + $settings, + ['country_code' => 'US'], + ); + + expect($result['total'])->toBe(0); +}); + +it('taxes shipping when shipping_taxable flag is set', function () { + $settings = makeTaxSettings([ + 'default_rate_bps' => 1000, + 'shipping_taxable' => true, + ]); + + $result = app(TaxCalculator::class)->calculate( + [['amount' => 1000]], + $settings, + ['country_code' => 'US'], + shippingAmount: 500, + ); + + expect($result['total'])->toBe(150) + ->and($result['lines'])->toHaveCount(2); +}); diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index fcd0258d..412a103c 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -15,4 +15,4 @@ $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 index 8b5843f4..2118bf29 100644 --- a/tests/Feature/ExampleTest.php +++ b/tests/Feature/ExampleTest.php @@ -1,7 +1,22 @@ get('/'); + $store = Store::factory()->create(['name' => 'Example']); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'example.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $response = $this->get('http://example.test/'); $response->assertStatus(200); }); diff --git a/tests/Feature/Orders/FulfillmentServiceTest.php b/tests/Feature/Orders/FulfillmentServiceTest.php new file mode 100644 index 00000000..17a38d30 --- /dev/null +++ b/tests/Feature/Orders/FulfillmentServiceTest.php @@ -0,0 +1,97 @@ +create(); + $order = Order::factory()->paid()->create(['store_id' => $store->getKey()]); + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'quantity' => $qty, + ]); + + return [$order, $line]; +} + +it('creates a fulfillment and transitions order to fulfilled when all lines covered', function () { + [$order, $line] = makeFulfillmentFixture(2); + + $fulfillment = app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 2], + ]); + + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Pending) + ->and($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->refresh()->status)->toBe(OrderStatus::Fulfilled); +}); + +it('transitions fulfillment through partial when only some quantity is fulfilled', function () { + [$order, $line] = makeFulfillmentFixture(5); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 2], + ]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Partial); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 3], + ]); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled); +}); + +it('rejects fulfilling more than unfulfilled quantity', function () { + [$order, $line] = makeFulfillmentFixture(2); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 5], + ]); +})->throws(RuntimeException::class); + +it('blocks fulfillment when financial status is pending', function () { + $store = Store::factory()->create(); + $order = Order::factory()->create([ + 'store_id' => $store->getKey(), + 'financial_status' => FinancialStatus::Pending->value, + ]); + $line = OrderLine::factory()->create(['order_id' => $order->getKey(), 'quantity' => 1]); + + app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 1], + ]); +})->throws(FulfillmentGuardException::class); + +it('transitions fulfillment from pending to shipped to delivered', function () { + [$order, $line] = makeFulfillmentFixture(1); + $fulfillment = app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 1], + ], ['tracking_company' => 'USPS', 'tracking_number' => 'Z123']); + + $fulfillment = app(FulfillmentService::class)->markAsShipped($fulfillment); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Shipped) + ->and($fulfillment->shipped_at)->not->toBeNull(); + + $fulfillment = app(FulfillmentService::class)->markAsDelivered($fulfillment); + expect($fulfillment->status)->toBe(FulfillmentShipmentStatus::Delivered); +}); + +it('rejects marking delivered before shipped', function () { + [$order, $line] = makeFulfillmentFixture(1); + $fulfillment = app(FulfillmentService::class)->create($order, [ + ['order_line_id' => (int) $line->getKey(), 'quantity' => 1], + ]); + + app(FulfillmentService::class)->markAsDelivered($fulfillment); +})->throws(RuntimeException::class); diff --git a/tests/Feature/Orders/OrderCheckoutTest.php b/tests/Feature/Orders/OrderCheckoutTest.php new file mode 100644 index 00000000..b15a7bf4 --- /dev/null +++ b/tests/Feature/Orders/OrderCheckoutTest.php @@ -0,0 +1,176 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1500, + 'requires_shipping' => $requiresShipping ? 1 : 0, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => $onHand, + 'quantity_reserved' => 0, + ]); + + $cart = app(CartService::class)->create($store); + app(CartService::class)->addLine($cart, (int) $variant->getKey(), $qty); + + $zone = ShippingZone::factory()->create([ + 'store_id' => $store->getKey(), + 'countries_json' => ['US'], + ]); + $rate = ShippingRate::factory()->create([ + 'zone_id' => $zone->getKey(), + 'config_json' => ['amount' => 500], + ]); + + TaxSettings::factory()->create([ + 'store_id' => $store->getKey(), + 'config_json' => ['default_rate_bps' => 0], + ]); + + $checkout = app(CheckoutService::class)->start($store, $cart); + $checkout = app(CheckoutService::class)->setAddress($checkout, [ + 'email' => 'buyer@example.test', + 'shipping_address' => [ + 'first_name' => 'A', + 'last_name' => 'B', + 'address1' => '1 Main', + 'city' => 'City', + 'country_code' => 'US', + 'postal_code' => '99999', + ], + ]); + + if ($requiresShipping) { + $checkout = app(CheckoutService::class)->setShippingMethod($checkout, (int) $rate->getKey()); + } + + return [$store, $cart, $variant, $checkout]; +} + +it('completes a successful card checkout', function () { + [, $cart, $variant, $checkout] = bootOrderFixture(onHand: 5, qty: 2); + + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $checkout->refresh(); + + $result = app(PaymentService::class)->authorize($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + $order = app(OrderService::class)->createFromCheckout($checkout); + $payment = app(PaymentService::class)->recordPayment($order, PaymentMethod::CreditCard, $result); + + expect($order->status)->toBe(OrderStatus::Paid) + ->and($order->financial_status)->toBe(FinancialStatus::Paid) + ->and($order->fulfillment_status)->toBe(FulfillmentStatus::Unfulfilled) + ->and($order->lines()->count())->toBe(1) + ->and($order->lines()->first()->quantity)->toBe(2) + ->and($payment->status)->toBe(PaymentStatus::Captured) + ->and($cart->refresh()->status)->toBe(CartStatus::Converted) + ->and((int) $variant->inventoryItem()->first()->quantity_on_hand)->toBe(3); +}); + +it('leaves no order and releases reservation on declined card', function () { + [, , $variant, $checkout] = bootOrderFixture(onHand: 3, qty: 1); + + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $checkout->refresh(); + + expect($variant->inventoryItem()->first()->quantity_reserved)->toBe(1); + + try { + app(PaymentService::class)->authorize($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_DECLINE, + ]); + } catch (PaymentFailedException $e) { + expect($e->errorCode)->toBe('card_declined'); + } + + expect(Order::query()->count())->toBe(0) + ->and($variant->inventoryItem()->first()->quantity_reserved)->toBe(0); +}); + +it('returns insufficient_funds for the 9995 magic card', function () { + [, , , $checkout] = bootOrderFixture(); + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + app(PaymentService::class)->authorize($checkout->refresh(), PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_INSUFFICIENT_FUNDS, + ]); +})->throws(PaymentFailedException::class, 'insufficient_funds'); + +it('creates a bank-transfer order with pending financial status', function () { + [, , $variant, $checkout] = bootOrderFixture(onHand: 10, qty: 1); + + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::BankTransfer); + $checkout->refresh(); + + $result = app(PaymentService::class)->authorize($checkout, PaymentMethod::BankTransfer, []); + $order = app(OrderService::class)->createFromCheckout($checkout); + $payment = app(PaymentService::class)->recordPayment($order, PaymentMethod::BankTransfer, $result); + + expect($order->financial_status)->toBe(FinancialStatus::Pending) + ->and($order->status)->toBe(OrderStatus::Pending) + ->and($payment->status)->toBe(PaymentStatus::Pending) + ->and($variant->inventoryItem()->first()->quantity_on_hand)->toBe(10) + ->and($variant->inventoryItem()->first()->quantity_reserved)->toBe(1); +}); + +it('auto-fulfills orders with only digital line items', function () { + [, , , $checkout] = bootOrderFixture(onHand: 5, qty: 1, requiresShipping: false); + + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + $checkout->refresh(); + + $result = app(PaymentService::class)->authorize($checkout, PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + $order = app(OrderService::class)->createFromCheckout($checkout); + app(PaymentService::class)->recordPayment($order, PaymentMethod::CreditCard, $result); + + expect($order->refresh()->fulfillment_status)->toBe(FulfillmentStatus::Fulfilled) + ->and($order->fulfillments()->count())->toBe(1); +}); + +it('is idempotent on duplicate provider_payment_id', function () { + [, , , $checkout] = bootOrderFixture(); + app(CheckoutService::class)->selectPaymentMethod($checkout, PaymentMethod::CreditCard); + + $result = app(PaymentService::class)->authorize($checkout->refresh(), PaymentMethod::CreditCard, [ + 'card_number' => MockPaymentProvider::CARD_SUCCESS, + ]); + $order = app(OrderService::class)->createFromCheckout($checkout); + + $p1 = app(PaymentService::class)->recordPayment($order, PaymentMethod::CreditCard, $result); + $p2 = app(PaymentService::class)->recordPayment($order, PaymentMethod::CreditCard, $result); + + expect($p1->getKey())->toBe($p2->getKey()); +}); diff --git a/tests/Feature/Orders/RefundServiceTest.php b/tests/Feature/Orders/RefundServiceTest.php new file mode 100644 index 00000000..9437bcf0 --- /dev/null +++ b/tests/Feature/Orders/RefundServiceTest.php @@ -0,0 +1,90 @@ +create(); + $product = Product::factory()->active()->create(['store_id' => $store->getKey()]); + $variant = ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => $total, + ]); + InventoryItem::factory()->create([ + 'store_id' => $store->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity_on_hand' => 10, + ]); + + $order = Order::factory()->paid()->create([ + 'store_id' => $store->getKey(), + 'subtotal_amount' => $total, + 'total_amount' => $total, + ]); + + $line = OrderLine::factory()->create([ + 'order_id' => $order->getKey(), + 'product_id' => $product->getKey(), + 'variant_id' => $variant->getKey(), + 'quantity' => 1, + 'unit_price_amount' => $total, + 'total_amount' => $total, + ]); + + $payment = Payment::factory()->create([ + 'order_id' => $order->getKey(), + 'amount' => $total, + ]); + + return [$store, $order, $payment, $line, $variant]; +} + +it('issues a partial refund and transitions to partially_refunded', function () { + [, $order, $payment] = makeRefundFixture(5000); + + $refund = app(RefundService::class)->create($order, $payment, 2000, reason: 'customer request'); + + expect($refund->amount)->toBe(2000) + ->and($order->refresh()->financial_status)->toBe(FinancialStatus::PartiallyRefunded); +}); + +it('issues a full refund and transitions to refunded', function () { + [, $order, $payment] = makeRefundFixture(3000); + + app(RefundService::class)->create($order, $payment, 3000); + + expect($order->refresh()->financial_status)->toBe(FinancialStatus::Refunded) + ->and($order->refresh()->status)->toBe(OrderStatus::Refunded); +}); + +it('restocks inventory when restock flag is true', function () { + [, $order, $payment, , $variant] = makeRefundFixture(1000); + $startingOnHand = (int) $variant->inventoryItem()->first()->quantity_on_hand; + + app(RefundService::class)->create($order, $payment, 1000, restock: true); + + expect((int) $variant->inventoryItem()->first()->quantity_on_hand)->toBe($startingOnHand + 1); +}); + +it('rejects refunds that exceed remaining refundable amount', function () { + [, $order, $payment] = makeRefundFixture(1000); + + app(RefundService::class)->create($order, $payment, 1500); +})->throws(RuntimeException::class); + +it('rejects zero or negative refund amounts', function () { + [, $order, $payment] = makeRefundFixture(1000); + + app(RefundService::class)->create($order, $payment, 0); +})->throws(RuntimeException::class); diff --git a/tests/Feature/Polish/AccessibilityTest.php b/tests/Feature/Polish/AccessibilityTest.php new file mode 100644 index 00000000..b5264032 --- /dev/null +++ b/tests/Feature/Polish/AccessibilityTest.php @@ -0,0 +1,44 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('includes a skip-to-content link, landmarks, and aria labels', function () { + seedAccessibilityStore(); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee('Skip to main content'); + $response->assertSee('id="main-content"', false); + $response->assertSee('role="main"', false); + $response->assertSee('role="contentinfo"', false); + $response->assertSee('aria-label="Primary"', false); + $response->assertSee('aria-label="Footer"', false); + $response->assertSee('aria-haspopup="dialog"', false); +}); + +it('marks the home link as current page on the homepage', function () { + seedAccessibilityStore(); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee('aria-current="page"', false); +}); diff --git a/tests/Feature/Polish/AnnouncementBarTest.php b/tests/Feature/Polish/AnnouncementBarTest.php new file mode 100644 index 00000000..a4cccaee --- /dev/null +++ b/tests/Feature/Polish/AnnouncementBarTest.php @@ -0,0 +1,73 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + StoreSettings::query()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => [ + 'announcement' => [ + 'enabled' => true, + 'text' => 'Sitewide 20% off', + 'link' => null, + ], + ], + ]); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee('Sitewide 20% off'); + $response->assertSee('aria-label="Announcement"', false); +}); + +it('falls back to the default announcement when none is configured', function () { + $store = Store::factory()->create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee('Free shipping on orders over $50'); +}); + +it('hides the announcement bar when explicitly disabled', function () { + $store = Store::factory()->create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + StoreSettings::query()->create([ + 'store_id' => $store->getKey(), + 'settings_json' => [ + 'announcement' => [ + 'enabled' => false, + 'text' => 'Should not appear', + ], + ], + ]); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertDontSee('Should not appear'); +}); diff --git a/tests/Feature/Polish/ErrorPagesTest.php b/tests/Feature/Polish/ErrorPagesTest.php new file mode 100644 index 00000000..7acc4800 --- /dev/null +++ b/tests/Feature/Polish/ErrorPagesTest.php @@ -0,0 +1,47 @@ +create(['name' => 'Shop', 'status' => $status->value]); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('renders a 404 page for unknown storefront URLs', function () { + seedPolishStore(); + + $response = $this->get('http://shop.test/pages/definitely-missing'); + + $response->assertNotFound(); + $response->assertSee('Page not found'); + $response->assertSee('Return home'); +}); + +it('renders a 503 page when the store is suspended', function () { + seedPolishStore('paused.test', StoreStatus::Suspended); + + $response = $this->get('http://paused.test/'); + + $response->assertStatus(503); + $response->assertSee('Temporarily unavailable'); +}); + +it('renders a 404 with a minimal fallback layout when the hostname is unknown', function () { + $response = $this->get('http://unknown-host.test/'); + + $response->assertNotFound(); + $response->assertSee('Return home'); +}); diff --git a/tests/Feature/Polish/HomepageResolvesTest.php b/tests/Feature/Polish/HomepageResolvesTest.php new file mode 100644 index 00000000..3ecbe763 --- /dev/null +++ b/tests/Feature/Polish/HomepageResolvesTest.php @@ -0,0 +1,22 @@ +create(['name' => 'Shop']); + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee($store->name); +}); diff --git a/tests/Feature/Search/SearchPageTest.php b/tests/Feature/Search/SearchPageTest.php new file mode 100644 index 00000000..e0eccfbd --- /dev/null +++ b/tests/Feature/Search/SearchPageTest.php @@ -0,0 +1,84 @@ +create([ + 'name' => 'Shop Holdings', + 'billing_email' => 'billing@'.$hostname, + ]); + + $store = Store::factory()->create([ + 'organization_id' => $org->getKey(), + 'handle' => 'shop', + 'name' => 'Shop', + ]); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + StoreSettings::query()->updateOrCreate( + ['store_id' => $store->getKey()], + ['settings_json' => '{}'], + ); + + return $store; +} + +it('renders the search page and shows matching products', function () { + $store = makeStorefront(); + + $product = Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Searchable Hat', + 'status' => ProductStatus::Active->value, + 'published_at' => now(), + ]); + + ProductVariant::factory()->create([ + 'product_id' => $product->getKey(), + 'price_amount' => 1999, + 'is_default' => 1, + ]); + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/search?q=Searchable'); + + $response->assertOk(); + $response->assertSee('Searchable Hat'); + $response->assertSee('19.99'); +}); + +it('renders the search page with no results message when nothing matches', function () { + makeStorefront(); + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/search?q=zzzxxxnomatch'); + + $response->assertOk(); + $response->assertSee('No products matched your search.'); +}); + +it('renders the empty search page with no query', function () { + makeStorefront(); + + $response = $this->withServerVariables(['HTTP_HOST' => 'shop.test']) + ->get('/search'); + + $response->assertOk(); + $response->assertSee('Search'); +}); diff --git a/tests/Feature/Search/SearchServiceTest.php b/tests/Feature/Search/SearchServiceTest.php new file mode 100644 index 00000000..9343e74a --- /dev/null +++ b/tests/Feature/Search/SearchServiceTest.php @@ -0,0 +1,123 @@ +create(array_merge([ + 'store_id' => $store->getKey(), + 'title' => $title, + 'status' => ProductStatus::Active->value, + 'published_at' => now(), + ], $overrides)); +} + +it('matches products by title', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Running Sneakers'); + makeActiveProduct($store, 'Winter Coat'); + + $results = app(SearchService::class)->search($store, 'running', [], null, false); + + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Running Sneakers'); +}); + +it('is case-insensitive', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Leather Wallet'); + + $results = app(SearchService::class)->search($store, 'LEATHER', [], null, false); + + expect($results)->toHaveCount(1); +}); + +it('supports prefix search on the last token', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Running Sneakers'); + + $results = app(SearchService::class)->search($store, 'running sne', [], null, false); + + expect($results)->toHaveCount(1); +}); + +it('returns empty when there are no matches', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Running Sneakers'); + + $results = app(SearchService::class)->search($store, 'zzz-nothing', [], null, false); + + expect($results)->toBeEmpty(); +}); + +it('returns empty for an empty query', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Running Sneakers'); + + $results = app(SearchService::class)->search($store, ' ', [], null, false); + + expect($results)->toBeEmpty(); +}); + +it('scopes results to the given store', function () { + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + makeActiveProduct($storeA, 'Shared Keyword A'); + makeActiveProduct($storeB, 'Shared Keyword B'); + + $results = app(SearchService::class)->search($storeA, 'shared', [], null, false); + + expect($results)->toHaveCount(1) + ->and($results->first()->store_id)->toBe($storeA->getKey()); +}); + +it('excludes non-active or unpublished products', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Live Shoe'); + Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Draft Shoe', + 'status' => ProductStatus::Draft->value, + 'published_at' => null, + ]); + Product::factory()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Archived Shoe', + 'status' => ProductStatus::Archived->value, + 'published_at' => now(), + ]); + + $results = app(SearchService::class)->search($store, 'shoe', [], null, false); + + expect($results)->toHaveCount(1) + ->and($results->first()->title)->toBe('Live Shoe'); +}); + +it('logs a search query record with result count', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Blue Mug'); + + app(SearchService::class)->search($store, 'mug', [], 'sess-abc'); + + $row = SearchQuery::query()->withoutGlobalScopes()->where('store_id', $store->getKey())->firstOrFail(); + + expect($row->query)->toBe('mug') + ->and($row->results_count)->toBe(1) + ->and($row->session_id)->toBe('sess-abc'); +}); + +it('sanitises FTS5 special characters', function () { + $store = Store::factory()->create(); + makeActiveProduct($store, 'Coffee Beans'); + + $results = app(SearchService::class)->search($store, 'coffee*) OR "', [], null, false); + + expect($results)->toHaveCount(1); +}); diff --git a/tests/Feature/Settings/PasswordUpdateTest.php b/tests/Feature/Settings/PasswordUpdateTest.php index a6379b2b..759e3b26 100644 --- a/tests/Feature/Settings/PasswordUpdateTest.php +++ b/tests/Feature/Settings/PasswordUpdateTest.php @@ -39,4 +39,4 @@ ->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 index 276e9fef..fa5f185e 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -75,4 +75,4 @@ $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 index e2d530fb..b57a3202 100644 --- a/tests/Feature/Settings/TwoFactorAuthenticationTest.php +++ b/tests/Feature/Settings/TwoFactorAuthenticationTest.php @@ -69,4 +69,4 @@ 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, ]); -}); \ No newline at end of file +}); diff --git a/tests/Feature/Storefront/HomepageTest.php b/tests/Feature/Storefront/HomepageTest.php new file mode 100644 index 00000000..a7aa5bb1 --- /dev/null +++ b/tests/Feature/Storefront/HomepageTest.php @@ -0,0 +1,30 @@ +create(['name' => $name]); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('renders the storefront home page', function () { + $store = seedStorefrontStore('shop.test', 'Shop'); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee($store->name); +}); diff --git a/tests/Feature/Storefront/NavigationTest.php b/tests/Feature/Storefront/NavigationTest.php new file mode 100644 index 00000000..4f079126 --- /dev/null +++ b/tests/Feature/Storefront/NavigationTest.php @@ -0,0 +1,56 @@ +create(['name' => 'Shop']); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'shop.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('renders menu items from the main-menu handle', function () { + $store = seedStorefrontStoreForNav(); + + $menu = NavigationMenu::query()->create([ + 'store_id' => $store->getKey(), + 'handle' => 'main-menu', + 'title' => 'Main menu', + ]); + + NavigationItem::query()->create([ + 'menu_id' => $menu->getKey(), + 'type' => NavigationItemType::Link->value, + 'label' => 'Home', + 'url' => '/', + 'position' => 0, + ]); + + NavigationItem::query()->create([ + 'menu_id' => $menu->getKey(), + 'type' => NavigationItemType::Link->value, + 'label' => 'Catalog', + 'url' => '/collections/all', + 'position' => 1, + ]); + + $response = $this->get('http://shop.test/'); + + $response->assertOk(); + $response->assertSee('Home'); + $response->assertSee('Catalog'); +}); diff --git a/tests/Feature/Storefront/PageShowTest.php b/tests/Feature/Storefront/PageShowTest.php new file mode 100644 index 00000000..a44d4373 --- /dev/null +++ b/tests/Feature/Storefront/PageShowTest.php @@ -0,0 +1,66 @@ +create(['name' => 'Shop']); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => $hostname, + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + return $store; +} + +it('renders a published page by handle', function () { + $store = seedStorefrontStoreForPage(); + + Page::query()->create([ + 'store_id' => $store->getKey(), + 'title' => 'About Us', + 'handle' => 'about', + 'body_html' => '

Hello visitors

', + 'status' => PageStatus::Published->value, + 'published_at' => now(), + ]); + + $response = $this->get('http://shop.test/pages/about'); + + $response->assertOk(); + $response->assertSee('About Us'); + $response->assertSee('Hello visitors', false); +}); + +it('404s for draft pages', function () { + $store = seedStorefrontStoreForPage(); + + Page::query()->create([ + 'store_id' => $store->getKey(), + 'title' => 'Secret', + 'handle' => 'secret', + 'body_html' => '

Not yet

', + 'status' => PageStatus::Draft->value, + ]); + + $response = $this->get('http://shop.test/pages/secret'); + + $response->assertNotFound(); +}); + +it('404s for unknown handles', function () { + seedStorefrontStoreForPage(); + + $response = $this->get('http://shop.test/pages/unknown-handle'); + + $response->assertNotFound(); +}); diff --git a/tests/Feature/Storefront/StoreResolutionTest.php b/tests/Feature/Storefront/StoreResolutionTest.php new file mode 100644 index 00000000..899794e0 --- /dev/null +++ b/tests/Feature/Storefront/StoreResolutionTest.php @@ -0,0 +1,32 @@ +get('http://unknown.test/'); + + $response->assertNotFound(); +}); + +it('returns 503 when the store is suspended', function () { + $store = Store::factory()->create([ + 'name' => 'Paused Shop', + 'status' => StoreStatus::Suspended->value, + ]); + + StoreDomain::factory()->create([ + 'store_id' => $store->getKey(), + 'hostname' => 'paused.test', + 'type' => StoreDomainType::Storefront->value, + 'is_primary' => 1, + ]); + + $response = $this->get('http://paused.test/'); + + $response->assertStatus(503); +}); diff --git a/tests/Feature/Webhooks/AdminWebhookPagesTest.php b/tests/Feature/Webhooks/AdminWebhookPagesTest.php new file mode 100644 index 00000000..171e6e66 --- /dev/null +++ b/tests/Feature/Webhooks/AdminWebhookPagesTest.php @@ -0,0 +1,71 @@ +create(); + \DB::table('store_users')->insert([ + 'store_id' => $store->getKey(), + 'user_id' => $user->getKey(), + 'role' => StoreUserRole::Owner->value, + 'created_at' => now(), + ]); + + return $user; +} + +it('renders the webhooks index page', function () { + $store = Store::factory()->create(); + $user = asOwner($store); + app()->instance('current_store', $store); + + $this->actingAs($user); + + Livewire::test(AdminWebhooksIndex::class) + ->assertStatus(200) + ->assertSee('Webhooks'); +}); + +it('saves a new webhook subscription', function () { + $store = Store::factory()->create(); + $user = asOwner($store); + app()->instance('current_store', $store); + + $this->actingAs($user); + + Livewire::test(AdminWebhookEdit::class) + ->set('event_type', 'order.paid') + ->set('target_url', 'https://example.test/hook') + ->set('signing_secret', 'mysecret-12345') + ->set('status', 'active') + ->call('save'); + + $subscription = WebhookSubscription::query()->where('store_id', $store->getKey())->first(); + + expect($subscription)->not->toBeNull() + ->and($subscription->target_url)->toBe('https://example.test/hook'); +}); + +it('renders the deliveries page', function () { + $store = Store::factory()->create(); + $user = asOwner($store); + app()->instance('current_store', $store); + + $this->actingAs($user); + + $subscription = WebhookSubscription::factory()->create(['store_id' => $store->getKey()]); + + Livewire::test(AdminWebhookDeliveries::class, ['subscription' => (int) $subscription->getKey()]) + ->assertStatus(200) + ->assertSee('Deliveries'); +}); diff --git a/tests/Feature/Webhooks/DeliverWebhookJobTest.php b/tests/Feature/Webhooks/DeliverWebhookJobTest.php new file mode 100644 index 00000000..7dbe62fe --- /dev/null +++ b/tests/Feature/Webhooks/DeliverWebhookJobTest.php @@ -0,0 +1,134 @@ +create(); + $subscription = WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + 'target_url' => 'https://example.test/webhook', + 'signing_secret_encrypted' => $secret, + 'status' => 'active', + ]); + + return [$subscription, $secret]; +} + +it('records a success delivery on 200 and posts the signed payload', function () { + Http::fake(['example.test/*' => Http::response('ok', 200)]); + + [$subscription, $secret] = makeWebhookFixture(); + $eventId = (string) Str::uuid(); + $payload = ['order_id' => 42]; + $timestamp = 1700000000; + + $job = new DeliverWebhook( + (int) $subscription->getKey(), + $eventId, + WebhookTopic::OrderPaid->value, + $payload, + $timestamp, + ); + $job->handle(); + + $delivery = WebhookDelivery::query()->where('subscription_id', $subscription->getKey())->latest('id')->first(); + + expect($delivery->status)->toBe('success') + ->and($delivery->response_code)->toBe(200); + + $expectedBody = json_encode([ + 'event_id' => $eventId, + 'topic' => WebhookTopic::OrderPaid->value, + 'timestamp' => $timestamp, + 'data' => $payload, + ]); + $expectedSignature = hash_hmac('sha256', (string) $expectedBody, $secret); + + Http::assertSent(function ($request) use ($expectedBody, $expectedSignature, $eventId, $subscription): bool { + return $request->url() === $subscription->target_url + && $request->header('X-Shop-Signature')[0] === $expectedSignature + && $request->header('X-Shop-Event-Id')[0] === $eventId + && $request->body() === $expectedBody; + }); +}); + +it('records failure and schedules next retry on 500', function () { + Http::fake(['example.test/*' => Http::response('boom', 500)]); + + [$subscription] = makeWebhookFixture(); + + $job = new DeliverWebhook( + (int) $subscription->getKey(), + (string) Str::uuid(), + WebhookTopic::OrderPaid->value, + ['k' => 'v'], + now()->timestamp, + ); + + try { + $job->handle(); + } catch (Throwable) { + // expected: job throws so Laravel can retry + } + + $delivery = WebhookDelivery::query()->where('subscription_id', $subscription->getKey())->latest('id')->first(); + + expect($delivery->status)->toBe('pending') + ->and($delivery->response_code)->toBe(500) + ->and($delivery->next_retry_at)->not->toBeNull() + ->and($subscription->refresh()->consecutive_failures)->toBe(1); +}); + +it('pauses the subscription after 5 consecutive failures', function () { + Http::fake(['example.test/*' => Http::response('boom', 500)]); + + [$subscription] = makeWebhookFixture(); + $subscription->consecutive_failures = 4; + $subscription->save(); + + $job = new DeliverWebhook( + (int) $subscription->getKey(), + (string) Str::uuid(), + WebhookTopic::OrderPaid->value, + [], + now()->timestamp, + ); + + try { + $job->handle(); + } catch (Throwable) { + } + + expect($subscription->refresh()->status)->toBe('paused') + ->and($subscription->consecutive_failures)->toBe(5); +}); + +it('skips delivery when subscription is not active', function () { + Http::fake(); + + [$subscription] = makeWebhookFixture(); + $subscription->status = 'paused'; + $subscription->save(); + + $job = new DeliverWebhook( + (int) $subscription->getKey(), + (string) Str::uuid(), + WebhookTopic::OrderPaid->value, + [], + now()->timestamp, + ); + $job->handle(); + + expect(WebhookDelivery::query()->count())->toBe(0); + Http::assertNothingSent(); +}); diff --git a/tests/Feature/Webhooks/OrderPaidTriggersWebhookTest.php b/tests/Feature/Webhooks/OrderPaidTriggersWebhookTest.php new file mode 100644 index 00000000..6093824b --- /dev/null +++ b/tests/Feature/Webhooks/OrderPaidTriggersWebhookTest.php @@ -0,0 +1,31 @@ +create(); + $order = Order::factory()->paid()->create(['store_id' => $store->getKey()]); + + WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + 'status' => 'active', + ]); + + OrderPaid::dispatch($order); + + Queue::assertPushed(DeliverWebhook::class, function ($job) use ($order): bool { + return $job->topic === WebhookTopic::OrderPaid->value + && $job->payload['order_id'] === (int) $order->getKey(); + }); +}); diff --git a/tests/Feature/Webhooks/WebhookDispatcherTest.php b/tests/Feature/Webhooks/WebhookDispatcherTest.php new file mode 100644 index 00000000..c1eff3c7 --- /dev/null +++ b/tests/Feature/Webhooks/WebhookDispatcherTest.php @@ -0,0 +1,56 @@ +create(); + WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + 'status' => 'active', + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + 'status' => 'paused', + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $store->getKey(), + 'event_type' => WebhookTopic::OrderCreated->value, + 'status' => 'active', + ]); + + $count = app(WebhookDispatcher::class)->dispatch(WebhookTopic::OrderPaid, (int) $store->getKey(), ['order_id' => 1]); + + expect($count)->toBe(1); + Queue::assertPushed(DeliverWebhook::class, 1); +}); + +it('scopes dispatch to the given store', function () { + Queue::fake(); + + $storeA = Store::factory()->create(); + $storeB = Store::factory()->create(); + + WebhookSubscription::factory()->create([ + 'store_id' => $storeA->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + ]); + WebhookSubscription::factory()->create([ + 'store_id' => $storeB->getKey(), + 'event_type' => WebhookTopic::OrderPaid->value, + ]); + + app(WebhookDispatcher::class)->dispatch(WebhookTopic::OrderPaid, (int) $storeA->getKey(), []); + + Queue::assertPushed(DeliverWebhook::class, 1); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 60f04a45..e811c8ab 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,46 +1,20 @@ extend(Tests\TestCase::class) - // ->use(Illuminate\Foundation\Testing\RefreshDatabase::class) ->in('Feature'); -/* -|-------------------------------------------------------------------------- -| Expectations -|-------------------------------------------------------------------------- -| -| When you're writing tests, you often need to check that values meet certain conditions. The -| "expect()" function gives you access to a set of "expectations" methods that you can use -| to assert different things. Of course, you may extend the Expectation API at any time. -| -*/ +pest()->extend(Tests\TestCase::class) + ->use(RefreshDatabase::class) + ->in('Browser'); + +pest()->browser()->timeout(15000); expect()->extend('toBeOne', function () { return $this->toBe(1); }); -/* -|-------------------------------------------------------------------------- -| Functions -|-------------------------------------------------------------------------- -| -| While Pest is very powerful out-of-the-box, you may have some testing code specific to your -| project that you don't want to repeat in every file. Here you can also expose helpers as -| global functions to help you to reduce the number of lines of code in your test files. -| -*/ - function something() { // ..