diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json
new file mode 100644
index 00000000..0bb6a0f1
--- /dev/null
+++ b/.ai/mcp/mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "./artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.cursor/mcp.json b/.cursor/mcp.json
new file mode 100644
index 00000000..ea301954
--- /dev/null
+++ b/.cursor/mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "./artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.cursor/rules/laravel-boost.mdc b/.cursor/rules/laravel-boost.mdc
new file mode 100644
index 00000000..da9e12e7
--- /dev/null
+++ b/.cursor/rules/laravel-boost.mdc
@@ -0,0 +1,367 @@
+---
+alwaysApply: true
+---
+
+=== 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 enhance the user's satisfaction 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.19
+- filament/filament (FILAMENT) - v5
+- laravel/cashier (CASHIER) - v15
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pennant (PENNANT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/flux-pro (FLUXUI_PRO) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- alpinejs (ALPINEJS) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v4
+
+## 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 it works. 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.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
+
+=== 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.
+
+=== 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 the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### 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.
+
+=== pennant/core rules ===
+
+## Laravel Pennant
+
+- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
+- Use the `search-docs` tool, in combination with existing codebase conventions, to assist the user effectively with feature flags.
+
+=== fluxui-pro/core rules ===
+
+## Flux UI Pro
+
+- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
+- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
+- You should use Flux UI components when available.
+- Fallback to standard Blade components if Flux is unavailable.
+- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
+- Flux UI components look like this:
+
+
+
+
+
+### Available Components
+This is correct as of Boost installation, but there may be additional components within the codebase.
+
+
+accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
+
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- 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.
+
+=== phpunit/core rules ===
+
+## PHPUnit
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/.env.example b/.env.example
index 898fd326..6f3fc12a 100644
--- a/.env.example
+++ b/.env.example
@@ -56,3 +56,38 @@ VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
+
+TORCHLIGHT_TOKEN=
+
+STRIPE_KEY=
+STRIPE_SECRET=
+STRIPE_WEBHOOK_SECRET=
+STRIPE_MINI_PRICE_ID=
+STRIPE_MINI_PRICE_ID_EAP=
+STRIPE_PRO_PRICE_ID=
+STRIPE_PRO_PRICE_ID_EAP=
+STRIPE_MAX_PRICE_ID=
+STRIPE_MAX_PRICE_ID_MONTHLY=
+STRIPE_MAX_PRICE_ID_EAP=
+STRIPE_ULTRA_COMP_PRICE_ID=
+STRIPE_EXTRA_SEAT_PRICE_ID=
+STRIPE_EXTRA_SEAT_PRICE_ID_MONTHLY=
+STRIPE_FOREVER_PRICE_ID=
+STRIPE_TRIAL_PRICE_ID=
+STRIPE_MINI_PAYMENT_LINK=
+STRIPE_PRO_PAYMENT_LINK=
+STRIPE_MAX_PAYMENT_LINK=
+STRIPE_FOREVER_PAYMENT_LINK=
+STRIPE_TRIAL_PAYMENT_LINK=
+
+ANYSTACK_API_KEY=
+ANYSTACK_PRODUCT_ID=
+ANYSTACK_MINI_POLICY_ID=
+ANYSTACK_PRO_POLICY_ID=
+ANYSTACK_MAX_POLICY_ID=
+ANYSTACK_FOREVER_POLICY_ID=
+ANYSTACK_TRIAL_POLICY_ID=
+
+FILAMENT_USERS=
+
+BIFROST_API_KEY=your-secure-api-key-here
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
new file mode 100644
index 00000000..d5a3f494
--- /dev/null
+++ b/.github/copilot-instructions.md
@@ -0,0 +1,364 @@
+
+=== 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 enhance the user's satisfaction 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.19
+- filament/filament (FILAMENT) - v5
+- laravel/cashier (CASHIER) - v15
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pennant (PENNANT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/flux-pro (FLUXUI_PRO) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- alpinejs (ALPINEJS) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v4
+
+## 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 it works. 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.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
+
+=== 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.
+
+=== 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 the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### 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.
+
+=== pennant/core rules ===
+
+## Laravel Pennant
+
+- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
+- Use the `search-docs` tool, in combination with existing codebase conventions, to assist the user effectively with feature flags.
+
+=== fluxui-pro/core rules ===
+
+## Flux UI Pro
+
+- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
+- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
+- You should use Flux UI components when available.
+- Fallback to standard Blade components if Flux is unavailable.
+- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
+- Flux UI components look like this:
+
+
+
+
+
+### Available Components
+This is correct as of Boost installation, but there may be additional components within the codebase.
+
+
+accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
+
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- 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.
+
+=== phpunit/core rules ===
+
+## PHPUnit
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/.github/funding.yml b/.github/funding.yml
index 23b9601c..5117700c 100644
--- a/.github/funding.yml
+++ b/.github/funding.yml
@@ -1,2 +1,2 @@
-github: simonhamp
+github: nativephp
open_collective: nativephp
diff --git a/.github/workflows/linting.yaml b/.github/workflows/linting.yaml
index 9313ab46..1cd305a9 100644
--- a/.github/workflows/linting.yaml
+++ b/.github/workflows/linting.yaml
@@ -11,7 +11,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest]
- php: [8.3]
+ php: [8.4]
steps:
- name: Checkout code
@@ -20,7 +20,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.3'
+ php-version: ${{ matrix.php }}
tools: composer:v2
- name: Copy .env
@@ -37,6 +37,9 @@ jobs:
key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer-
+ - name: Configure Flux Pro auth
+ run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_EMAIL }}" "${{ secrets.FLUX_LICENSE_KEY }}"
+
- name: Install Dependencies
run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 00000000..eedef6a6
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,67 @@
+# Unit and features tests
+# also useful for testing new php versions
+name: Tests
+on:
+ workflow_dispatch:
+ push:
+ branches-ignore:
+ - 'dependabot/npm_and_yarn/*'
+
+jobs:
+ tests:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [ubuntu-latest]
+ php: [8.4]
+# services:
+# redis:
+# image: redis
+# ports:
+# - 6379:6379
+# options: --health-cmd="redis-cli ping" --health-interval=10s --health-timeout=5s --health-retries=3
+
+ steps:
+
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ tools: composer:v2
+ coverage: xdebug
+
+ - name: Copy .env
+ run: cp .env.example .env
+
+ - name: Get composer cache directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-${{ matrix.php }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-${{ matrix.php }}-composer-
+
+ - name: Configure Flux Pro auth
+ run: composer config http-basic.composer.fluxui.dev "${{ secrets.FLUX_EMAIL }}" "${{ secrets.FLUX_LICENSE_KEY }}"
+
+ - name: Install Dependencies
+ run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
+
+ - name: Generate Application Key
+ run: php artisan key:generate
+
+ - name: Compile Assets
+ run: npm install && npm run build
+
+ - name: Run tests
+ env:
+ DB_CONNECTION: sqlite
+ DB_DATABASE: ":memory:"
+ QUEUE_CONNECTION: sync
+ run: ./vendor/bin/phpunit --testdox
diff --git a/.gitignore b/.gitignore
index 7fe978f8..9713846c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,19 +1,55 @@
+# php unit
/.phpunit.cache
+.phpunit.result.cache
+
+# auth
+auth.json
+
+# node_modules
/node_modules
-/public/build
+
+# public
/public/hot
/public/storage
+/public/build
+/public/sitemap.xml
+
+# storage
/storage/*.key
+
+# vendor
/vendor
-.env
-.env.backup
-.env.production
-.phpunit.result.cache
+
+# ide
+/.fleet
+/.idea
+/.vscode
+_ide_helper*
+.phpstorm.meta.php
+
+# .env
+.env*
+
+# cache
+*.cache
+
+# homestead
Homestead.json
Homestead.yaml
-auth.json
+
+# docker
+docker-compose.override.yml
+
+# package managers
npm-debug.log
yarn-error.log
-/.fleet
-/.idea
-/.vscode
+
+# logs
+worker.log
+
+# windows
+*:Zone.Identifier
+Thumbs.db
+
+# mac
+.DS_Store
\ No newline at end of file
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
new file mode 100644
index 00000000..d5a3f494
--- /dev/null
+++ b/.junie/guidelines.md
@@ -0,0 +1,364 @@
+
+=== 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 enhance the user's satisfaction 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.19
+- filament/filament (FILAMENT) - v5
+- laravel/cashier (CASHIER) - v15
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pennant (PENNANT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/flux-pro (FLUXUI_PRO) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- alpinejs (ALPINEJS) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v4
+
+## 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 it works. 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.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
+
+=== 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.
+
+=== 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 the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### 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.
+
+=== pennant/core rules ===
+
+## Laravel Pennant
+
+- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
+- Use the `search-docs` tool, in combination with existing codebase conventions, to assist the user effectively with feature flags.
+
+=== fluxui-pro/core rules ===
+
+## Flux UI Pro
+
+- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
+- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
+- You should use Flux UI components when available.
+- Fallback to standard Blade components if Flux is unavailable.
+- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
+- Flux UI components look like this:
+
+
+
+
+
+### Available Components
+This is correct as of Boost installation, but there may be additional components within the codebase.
+
+
+accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
+
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- 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.
+
+=== phpunit/core rules ===
+
+## PHPUnit
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/.junie/mcp/mcp.json b/.junie/mcp/mcp.json
new file mode 100644
index 00000000..0fd7d4ef
--- /dev/null
+++ b/.junie/mcp/mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": ["./artisan", "boost:mcp"]
+ }
+ }
+}
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 00000000..a68c0f44
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,15 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "php",
+ "args": [
+ "./artisan",
+ "boost:mcp"
+ ]
+ },
+ "nightwatch": {
+ "type": "http",
+ "url": "https://nightwatch.laravel.com/mcp"
+ }
+ }
+}
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..705d41a7
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,3 @@
+**/dist
+**/package-lock.json
+public/build
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 00000000..35a144fa
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,16 @@
+{
+ "plugins": ["prettier-plugin-blade", "prettier-plugin-tailwindcss"],
+ "overrides": [
+ {
+ "files": ["*.blade.php"],
+ "options": {
+ "parser": "blade"
+ }
+ }
+ ],
+ "tailwindStylesheet": "./resources/css/app.css",
+ "singleQuote": true,
+ "semi": false,
+ "trailingComma": "all",
+ "singleAttributePerLine": true
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..d5a3f494
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,364 @@
+
+=== 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 enhance the user's satisfaction 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.19
+- filament/filament (FILAMENT) - v5
+- laravel/cashier (CASHIER) - v15
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pennant (PENNANT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/flux-pro (FLUXUI_PRO) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- alpinejs (ALPINEJS) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v4
+
+## 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 it works. 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.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
+
+=== 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.
+
+=== 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 the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### 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.
+
+=== pennant/core rules ===
+
+## Laravel Pennant
+
+- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
+- Use the `search-docs` tool, in combination with existing codebase conventions, to assist the user effectively with feature flags.
+
+=== fluxui-pro/core rules ===
+
+## Flux UI Pro
+
+- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
+- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
+- You should use Flux UI components when available.
+- Fallback to standard Blade components if Flux is unavailable.
+- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
+- Flux UI components look like this:
+
+
+
+
+
+### Available Components
+This is correct as of Boost installation, but there may be additional components within the codebase.
+
+
+accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
+
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- 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.
+
+=== phpunit/core rules ===
+
+## PHPUnit
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..d5a3f494
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,364 @@
+
+=== 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 enhance the user's satisfaction 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.19
+- filament/filament (FILAMENT) - v5
+- laravel/cashier (CASHIER) - v15
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/nightwatch (NIGHTWATCH) - v1
+- laravel/pennant (PENNANT) - v1
+- laravel/prompts (PROMPTS) - v0
+- laravel/sanctum (SANCTUM) - v4
+- laravel/socialite (SOCIALITE) - v5
+- livewire/flux (FLUXUI_FREE) - v2
+- livewire/flux-pro (FLUXUI_PRO) - v2
+- livewire/livewire (LIVEWIRE) - v4
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+- rector/rector (RECTOR) - v2
+- alpinejs (ALPINEJS) - v3
+- prettier (PRETTIER) - v3
+- tailwindcss (TAILWINDCSS) - v4
+
+## 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 it works. 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.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches when dealing with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The `search-docs` tool is perfect for all Laravel-related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic-based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless there is something very complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+=== herd rules ===
+
+## Laravel Herd
+
+- The application is served by Laravel Herd and will be available at: `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate URLs for the user to ensure valid URLs.
+- You must not run any commands to make the site available via HTTP(S). It is always available through Laravel Herd.
+
+=== 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.
+
+=== 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 the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries.
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version-specific documentation.
+- This project upgraded from Laravel 10 without migrating to the new streamlined Laravel file structure.
+- This is **perfectly fine** and recommended by Laravel. Follow the existing structure from Laravel 10. We do not need to migrate to the new Laravel structure unless the user explicitly requests it.
+
+### Laravel 10 Structure
+- Middleware typically lives in `app/Http/Middleware/` and service providers in `app/Providers/`.
+- There is no `bootstrap/app.php` application configuration in a Laravel 10 structure:
+ - Middleware registration happens in `app/Http/Kernel.php`
+ - Exception handling is in `app/Exceptions/Handler.php`
+ - Console commands and schedule register in `app/Console/Kernel.php`
+ - Rate limits likely exist in `RouteServiceProvider` or `app/Http/Kernel.php`
+
+### 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.
+
+=== pennant/core rules ===
+
+## Laravel Pennant
+
+- This application uses Laravel Pennant for feature flag management, providing a flexible system for controlling feature availability across different organizations and user types.
+- Use the `search-docs` tool, in combination with existing codebase conventions, to assist the user effectively with feature flags.
+
+=== fluxui-pro/core rules ===
+
+## Flux UI Pro
+
+- This project is using the Pro version of Flux UI. It has full access to the free components and variants, as well as full access to the Pro components and variants.
+- Flux UI is a component library for Livewire. Flux is a robust, hand-crafted UI component library for your Livewire applications. It's built using Tailwind CSS and provides a set of components that are easy to use and customize.
+- You should use Flux UI components when available.
+- Fallback to standard Blade components if Flux is unavailable.
+- If available, use the `search-docs` tool to get the exact documentation and code snippets available for this project.
+- Flux UI components look like this:
+
+
+
+
+
+### Available Components
+This is correct as of Boost installation, but there may be additional components within the codebase.
+
+
+accordion, autocomplete, avatar, badge, brand, breadcrumbs, button, calendar, callout, card, chart, checkbox, command, composer, context, date-picker, dropdown, editor, field, file-upload, heading, icon, input, kanban, modal, navbar, otp-input, pagination, pillbox, popover, profile, radio, select, separator, skeleton, slider, switch, table, tabs, text, textarea, time-picker, toast, tooltip
+
+
+=== livewire/core rules ===
+
+## Livewire
+
+- Use the `search-docs` tool to find exact version-specific documentation for how to write Livewire and Livewire tests.
+- Use the `php artisan make:livewire [Posts\CreatePost]` Artisan command to create new components.
+- State should live on the server, with the UI reflecting it.
+- All Livewire requests hit the Laravel backend; they're like regular HTTP requests. Always validate form data and run authorization checks in Livewire actions.
+
+## Livewire Best Practices
+- Livewire components require a single root element.
+- Use `wire:loading` and `wire:dirty` for delightful loading states.
+- Add `wire:key` in loops:
+
+ ```blade
+ @foreach ($items as $item)
+
+ {{ $item->name }}
+
+ @endforeach
+ ```
+
+- Prefer lifecycle hooks like `mount()`, `updatedFoo()` for initialization and reactive side effects:
+
+
+ public function mount(User $user) { $this->user = $user; }
+ public function updatedSearch() { $this->resetPage(); }
+
+
+## Testing Livewire
+
+
+ Livewire::test(Counter::class)
+ ->assertSet('count', 0)
+ ->call('increment')
+ ->assertSet('count', 1)
+ ->assertSee(1)
+ ->assertStatus(200);
+
+
+
+ $this->get('/posts/create')
+ ->assertSeeLivewire(CreatePost::class);
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- 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.
+
+=== phpunit/core rules ===
+
+## PHPUnit
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `php artisan test --compact`.
+- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
+
+=== tailwindcss/core rules ===
+
+## Tailwind CSS
+
+- Use Tailwind CSS classes to style HTML; check and use existing Tailwind conventions within the project before writing your own.
+- Offer to extract repeated patterns into components that match the project's conventions (i.e. Blade, JSX, Vue, etc.).
+- Think through class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child carefully to limit repetition, and group elements logically.
+- You can use the `search-docs` tool to get exact examples from the official documentation when needed.
+
+### Spacing
+- When listing items, use gap utilities for spacing; don't use margins.
+
+
+
+
Superior
+
Michigan
+
Erie
+
+
+
+### Dark Mode
+- If existing pages and components support dark mode, new pages and components must support dark mode in a similar way, typically using `dark:`.
+
+=== tailwindcss/v4 rules ===
+
+## Tailwind CSS 4
+
+- Always use Tailwind CSS v4; do not use the deprecated utilities.
+- `corePlugins` is not supported in Tailwind v4.
+- In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed.
+
+
+@theme {
+ --color-brand: oklch(0.72 0.11 178);
+}
+
+
+- In Tailwind v4, you import Tailwind using a regular CSS `@import` statement, not using the `@tailwind` directives used in v3:
+
+
+ - @tailwind base;
+ - @tailwind components;
+ - @tailwind utilities;
+ + @import "tailwindcss";
+
+
+### Replaced Utilities
+- Tailwind v4 removed deprecated utilities. Do not use the deprecated option; use the replacement.
+- Opacity values are still numeric.
+
+| Deprecated | Replacement |
+|------------+--------------|
+| bg-opacity-* | bg-black/* |
+| text-opacity-* | text-black/* |
+| border-opacity-* | border-black/* |
+| divide-opacity-* | divide-black/* |
+| ring-opacity-* | ring-black/* |
+| placeholder-opacity-* | placeholder-black/* |
+| flex-shrink-* | shrink-* |
+| flex-grow-* | grow-* |
+| overflow-ellipsis | text-ellipsis |
+| decoration-slice | box-decoration-slice |
+| decoration-clone | box-decoration-clone |
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..cc8a1a84
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,22 @@
+Thank you for contributing!
+
+## Getting Started
+
+Be sure to fork and clone the repo: `gh repo clone NativePHP/nativephp.com`
+
+Make a branch for what change you want to contribute and open a pull request to the upstream main branch.
+
+## Torchlight
+
+The documentation uses [torchlight.dev](torchlight.dev) for syntax highlighting.
+
+Without a token, you will run into an error on pages like "/docs/desktop/1/getting-started/introduction".
+
+To get set up:
+
+1. Create an account and then make a new API token [here](https://torchlight.dev/account/api-tokens).
+2. Then set the API token in the .env.
+
+```
+TORCHLIGHT_TOKEN=
+```
diff --git a/README.md b/README.md
index a8e3fadc..265f003b 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,8 @@ This repo contains the content of the nativephp.com website.
We welcome any contributions to improve the documentation. Please feel free to open a PR.
+Also, be sure to read our [contributing guidelines](CONTRIBUTING.md) for help getting set up.
+
## Issues
-Please raise any issues on the [NativePHP/laravel](https://github.com/NativePHP/laravel/issues) repo.
+Please raise any issues on the [NativePHP/desktop](https://github.com/nativephp/desktop/issues) repo.
diff --git a/app/Actions/Licenses/DeleteLicense.php b/app/Actions/Licenses/DeleteLicense.php
new file mode 100644
index 00000000..b591999b
--- /dev/null
+++ b/app/Actions/Licenses/DeleteLicense.php
@@ -0,0 +1,23 @@
+license($license->anystack_id, $license->anystack_product_id)
+ ->delete();
+ }
+
+ return $license->delete();
+ }
+}
diff --git a/app/Actions/Licenses/RotateLicenseKey.php b/app/Actions/Licenses/RotateLicenseKey.php
new file mode 100644
index 00000000..b7e9584b
--- /dev/null
+++ b/app/Actions/Licenses/RotateLicenseKey.php
@@ -0,0 +1,47 @@
+createNewAnystackLicense($license);
+
+ $oldAnystackId = $license->anystack_id;
+
+ $license->update([
+ 'anystack_id' => $newLicenseData['id'],
+ 'key' => $newLicenseData['key'],
+ ]);
+
+ $this->suspendOldAnystackLicense($license, $oldAnystackId);
+
+ return $license;
+ }
+
+ private function createNewAnystackLicense(License $license): array
+ {
+ return Anystack::api()
+ ->licenses($license->anystack_product_id)
+ ->create([
+ 'policy_id' => $license->subscriptionType->anystackPolicyId(),
+ 'contact_id' => $license->user->anystack_contact_id,
+ ])
+ ->json('data');
+ }
+
+ private function suspendOldAnystackLicense(License $license, string $oldAnystackId): void
+ {
+ Anystack::api()
+ ->license($oldAnystackId, $license->anystack_product_id)
+ ->suspend();
+ }
+}
diff --git a/app/Actions/Licenses/SuspendLicense.php b/app/Actions/Licenses/SuspendLicense.php
new file mode 100644
index 00000000..8fe4383a
--- /dev/null
+++ b/app/Actions/Licenses/SuspendLicense.php
@@ -0,0 +1,25 @@
+license($license->anystack_id, $license->anystack_product_id)
+ ->suspend();
+
+ $license->update([
+ 'is_suspended' => true,
+ ]);
+
+ return $license;
+ }
+}
diff --git a/app/Actions/Licenses/UnsuspendLicense.php b/app/Actions/Licenses/UnsuspendLicense.php
new file mode 100644
index 00000000..a35d5c21
--- /dev/null
+++ b/app/Actions/Licenses/UnsuspendLicense.php
@@ -0,0 +1,25 @@
+license($license->anystack_id, $license->anystack_product_id)
+ ->suspend(false);
+
+ $license->update([
+ 'is_suspended' => false,
+ ]);
+
+ return $license;
+ }
+}
diff --git a/app/Actions/SubLicenses/DeleteSubLicense.php b/app/Actions/SubLicenses/DeleteSubLicense.php
new file mode 100644
index 00000000..e413fe45
--- /dev/null
+++ b/app/Actions/SubLicenses/DeleteSubLicense.php
@@ -0,0 +1,21 @@
+license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId())
+ ->delete();
+
+ return $subLicense->delete();
+ }
+}
diff --git a/app/Actions/SubLicenses/SuspendSubLicense.php b/app/Actions/SubLicenses/SuspendSubLicense.php
new file mode 100644
index 00000000..13b81dfe
--- /dev/null
+++ b/app/Actions/SubLicenses/SuspendSubLicense.php
@@ -0,0 +1,25 @@
+license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId())
+ ->suspend();
+
+ $subLicense->update([
+ 'is_suspended' => true,
+ ]);
+
+ return $subLicense;
+ }
+}
diff --git a/app/Actions/SubLicenses/UnsuspendSubLicense.php b/app/Actions/SubLicenses/UnsuspendSubLicense.php
new file mode 100644
index 00000000..f15c5345
--- /dev/null
+++ b/app/Actions/SubLicenses/UnsuspendSubLicense.php
@@ -0,0 +1,25 @@
+license($subLicense->anystack_id, $subLicense->parentLicense->subscriptionType->anystackProductId())
+ ->suspend(false);
+
+ $subLicense->update([
+ 'is_suspended' => false,
+ ]);
+
+ return $subLicense;
+ }
+}
diff --git a/app/Console/Commands/BackfillSubscriptionPrices.php b/app/Console/Commands/BackfillSubscriptionPrices.php
new file mode 100644
index 00000000..dfa89fa6
--- /dev/null
+++ b/app/Console/Commands/BackfillSubscriptionPrices.php
@@ -0,0 +1,66 @@
+get();
+
+ if ($subscriptions->isEmpty()) {
+ $this->info('No subscriptions need backfilling.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Backfilling {$subscriptions->count()} subscriptions...");
+
+ $bar = $this->output->createProgressBar($subscriptions->count());
+ $bar->start();
+
+ $errors = 0;
+
+ foreach ($subscriptions as $subscription) {
+ try {
+ $invoices = Cashier::stripe()->invoices->all([
+ 'subscription' => $subscription->stripe_id,
+ 'limit' => 1,
+ ]);
+
+ if (! empty($invoices->data)) {
+ $subscription->update([
+ 'price_paid' => max(0, $invoices->data[0]->total),
+ ]);
+ }
+ } catch (\Exception $e) {
+ $errors++;
+ $this->newLine();
+ $this->error("Failed for subscription {$subscription->stripe_id}: {$e->getMessage()}");
+ }
+
+ $bar->advance();
+ Sleep::usleep(100_000);
+ }
+
+ $bar->finish();
+ $this->newLine(2);
+
+ if ($errors > 0) {
+ $this->warn("{$errors} subscription(s) failed to backfill.");
+ }
+
+ $this->info('Backfill complete.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/CompUltraSubscription.php b/app/Console/Commands/CompUltraSubscription.php
new file mode 100644
index 00000000..170f339f
--- /dev/null
+++ b/app/Console/Commands/CompUltraSubscription.php
@@ -0,0 +1,59 @@
+error('STRIPE_ULTRA_COMP_PRICE_ID is not configured.');
+
+ return self::FAILURE;
+ }
+
+ $email = $this->argument('email');
+ $user = User::where('email', $email)->first();
+
+ if (! $user) {
+ $this->error("User not found: {$email}");
+
+ return self::FAILURE;
+ }
+
+ $existingSubscription = $user->subscription('default');
+
+ if ($existingSubscription && $existingSubscription->active()) {
+ $currentPlan = 'unknown';
+
+ try {
+ $currentPlan = Subscription::fromStripePriceId(
+ $existingSubscription->items->first()?->stripe_price ?? $existingSubscription->stripe_price
+ )->name();
+ } catch (\Exception) {
+ }
+
+ $this->error("User already has an active {$currentPlan} subscription. Cancel it first or use swap.");
+
+ return self::FAILURE;
+ }
+
+ $user->createOrGetStripeCustomer();
+
+ $user->newSubscription('default', $compedPriceId)->create();
+
+ $this->info("Comped Ultra subscription created for {$email}.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ExtendLicenseExpiryCommand.php b/app/Console/Commands/ExtendLicenseExpiryCommand.php
new file mode 100644
index 00000000..8bc3de90
--- /dev/null
+++ b/app/Console/Commands/ExtendLicenseExpiryCommand.php
@@ -0,0 +1,47 @@
+argument('license_id');
+
+ // Find the license
+ $license = License::find($licenseId);
+ if (! $license) {
+ $this->error("License with ID {$licenseId} not found");
+
+ return Command::FAILURE;
+ }
+
+ // Dispatch the job to update the license expiry
+ dispatch(new UpdateAnystackLicenseExpiryJob($license));
+
+ $this->info("License expiry updated to {$license->expires_at->format('Y-m-d')}");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/GeneratePluginLicenseKeys.php b/app/Console/Commands/GeneratePluginLicenseKeys.php
new file mode 100644
index 00000000..6045af79
--- /dev/null
+++ b/app/Console/Commands/GeneratePluginLicenseKeys.php
@@ -0,0 +1,65 @@
+option('force');
+
+ $query = User::query();
+
+ if (! $force) {
+ $query->whereNull('plugin_license_key');
+ }
+
+ $count = $query->count();
+
+ if ($count === 0) {
+ $this->info('All users already have plugin license keys.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Generating plugin license keys for {$count} users...");
+
+ $bar = $this->output->createProgressBar($count);
+ $bar->start();
+
+ $query->chunkById(100, function ($users) use ($bar): void {
+ foreach ($users as $user) {
+ $user->getPluginLicenseKey();
+ $bar->advance();
+ }
+ });
+
+ $bar->finish();
+ $this->newLine();
+
+ $this->info("Generated plugin license keys for {$count} users.");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/GrantPluginToBundleOwners.php b/app/Console/Commands/GrantPluginToBundleOwners.php
new file mode 100644
index 00000000..07acb0d6
--- /dev/null
+++ b/app/Console/Commands/GrantPluginToBundleOwners.php
@@ -0,0 +1,118 @@
+argument('bundle'))->first();
+
+ if (! $bundle) {
+ $this->error("Bundle not found: {$this->argument('bundle')}");
+
+ return Command::FAILURE;
+ }
+
+ $plugin = Plugin::where('name', $this->argument('plugin'))->first();
+
+ if (! $plugin) {
+ $this->error("Plugin not found: {$this->argument('plugin')}");
+
+ return Command::FAILURE;
+ }
+
+ $dryRun = $this->option('dry-run');
+ $noEmail = $this->option('no-email');
+
+ // Find all unique users who have purchased this bundle
+ // (they have at least one active PluginLicense linked to this bundle)
+ $userIds = PluginLicense::where('plugin_bundle_id', $bundle->id)
+ ->active()
+ ->distinct()
+ ->pluck('user_id');
+
+ $users = User::whereIn('id', $userIds)->get();
+
+ if ($users->isEmpty()) {
+ $this->warn('No users found who have purchased this bundle.');
+
+ return Command::SUCCESS;
+ }
+
+ $this->info("Bundle: {$bundle->name} (slug: {$bundle->slug})");
+ $this->info("Plugin: {$plugin->name}");
+ $this->info("Users found: {$users->count()}");
+
+ if ($dryRun) {
+ $this->warn('[DRY RUN] No changes will be made.');
+ }
+
+ $this->newLine();
+
+ $granted = 0;
+ $skipped = 0;
+
+ foreach ($users as $user) {
+ // Check if user already has an active license for this plugin
+ $existingLicense = PluginLicense::where('user_id', $user->id)
+ ->where('plugin_id', $plugin->id)
+ ->active()
+ ->exists();
+
+ if ($existingLicense) {
+ $this->line(" Skipped {$user->email} — already has an active license");
+ $skipped++;
+
+ continue;
+ }
+
+ if (! $dryRun) {
+ PluginLicense::create([
+ 'user_id' => $user->id,
+ 'plugin_id' => $plugin->id,
+ 'plugin_bundle_id' => $bundle->id,
+ 'price_paid' => 0,
+ 'currency' => 'USD',
+ 'is_grandfathered' => true,
+ 'purchased_at' => now(),
+ ]);
+
+ if (! $noEmail) {
+ $user->notify(
+ (new BundlePluginAdded($plugin, $bundle))
+ ->delay(now()->addSeconds($granted * 2))
+ );
+ }
+ }
+
+ $this->line(" Granted to {$user->email}");
+ $granted++;
+ }
+
+ $this->newLine();
+ $this->info("Granted: {$granted}");
+ $this->info("Skipped (already licensed): {$skipped}");
+
+ if ($dryRun) {
+ $this->warn('This was a dry run. Run again without --dry-run to apply changes.');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/GrantProduct.php b/app/Console/Commands/GrantProduct.php
new file mode 100644
index 00000000..e08ab13e
--- /dev/null
+++ b/app/Console/Commands/GrantProduct.php
@@ -0,0 +1,85 @@
+argument('product'))->first();
+
+ if (! $product) {
+ $this->error("Product not found: {$this->argument('product')}");
+
+ return Command::FAILURE;
+ }
+
+ $user = User::where('email', $this->argument('user'))->first();
+
+ if (! $user) {
+ $this->error("User not found: {$this->argument('user')}");
+
+ return Command::FAILURE;
+ }
+
+ $dryRun = $this->option('dry-run');
+ $noEmail = $this->option('no-email');
+
+ $this->info("Product: {$product->name} (slug: {$product->slug})");
+ $this->info("User: {$user->email}");
+
+ if ($dryRun) {
+ $this->warn('[DRY RUN] No changes will be made.');
+ }
+
+ $this->newLine();
+
+ // Check if user already has a license for this product
+ $existingLicense = ProductLicense::where('user_id', $user->id)
+ ->where('product_id', $product->id)
+ ->exists();
+
+ if ($existingLicense) {
+ $this->warn("User {$user->email} already has a license for this product.");
+
+ return Command::SUCCESS;
+ }
+
+ if (! $dryRun) {
+ ProductLicense::create([
+ 'user_id' => $user->id,
+ 'product_id' => $product->id,
+ 'price_paid' => 0,
+ 'currency' => 'USD',
+ 'is_comped' => true,
+ 'purchased_at' => now(),
+ ]);
+
+ if (! $noEmail) {
+ $user->notify(new ProductGranted($product));
+ }
+ }
+
+ $this->info("Granted to {$user->email}");
+
+ if ($dryRun) {
+ $this->warn('This was a dry run. Run again without --dry-run to apply changes.');
+ }
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ImportAnystackContactsCommand.php b/app/Console/Commands/ImportAnystackContactsCommand.php
new file mode 100644
index 00000000..f6a96e81
--- /dev/null
+++ b/app/Console/Commands/ImportAnystackContactsCommand.php
@@ -0,0 +1,23 @@
+name('import-anystack-contacts')
+ ->dispatch();
+ }
+}
diff --git a/app/Console/Commands/ImportAnystackLicensesCommand.php b/app/Console/Commands/ImportAnystackLicensesCommand.php
new file mode 100644
index 00000000..5793e6d8
--- /dev/null
+++ b/app/Console/Commands/ImportAnystackLicensesCommand.php
@@ -0,0 +1,23 @@
+name('import-anystack-licenses')
+ ->dispatch();
+ }
+}
diff --git a/app/Console/Commands/MarkCompedSubscriptions.php b/app/Console/Commands/MarkCompedSubscriptions.php
new file mode 100644
index 00000000..11a67e7e
--- /dev/null
+++ b/app/Console/Commands/MarkCompedSubscriptions.php
@@ -0,0 +1,125 @@
+argument('file');
+
+ if (! file_exists($path)) {
+ $this->error("File not found: {$path}");
+
+ return self::FAILURE;
+ }
+
+ $emails = $this->parseEmails($path);
+
+ if (empty($emails)) {
+ $this->error('No valid email addresses found in the file.');
+
+ return self::FAILURE;
+ }
+
+ $this->info('Found '.count($emails).' email(s) to process.');
+
+ $updated = 0;
+ $skipped = [];
+
+ foreach ($emails as $email) {
+ $user = User::where('email', $email)->first();
+
+ if (! $user) {
+ $skipped[] = "{$email} — user not found";
+
+ continue;
+ }
+
+ $subscription = Subscription::where('user_id', $user->id)
+ ->where('stripe_status', 'active')
+ ->first();
+
+ if (! $subscription) {
+ $skipped[] = "{$email} — no active subscription";
+
+ continue;
+ }
+
+ if ($subscription->is_comped) {
+ $skipped[] = "{$email} — already marked as comped";
+
+ continue;
+ }
+
+ $subscription->update(['is_comped' => true]);
+ $updated++;
+ $this->info("Marked {$email} as comped (subscription #{$subscription->id})");
+ }
+
+ if (count($skipped) > 0) {
+ $this->warn('Skipped:');
+ foreach ($skipped as $reason) {
+ $this->warn(" - {$reason}");
+ }
+ }
+
+ $this->info("Done. {$updated} subscription(s) marked as comped.");
+
+ return self::SUCCESS;
+ }
+
+ /**
+ * Parse email addresses from a CSV file.
+ * Supports: plain list (one email per line), or CSV with an "email" column header.
+ *
+ * @return array
+ */
+ private function parseEmails(string $path): array
+ {
+ $handle = fopen($path, 'r');
+
+ if (! $handle) {
+ return [];
+ }
+
+ $emails = [];
+ $emailColumnIndex = null;
+ $isFirstRow = true;
+
+ while (($row = fgetcsv($handle)) !== false) {
+ if ($isFirstRow) {
+ $isFirstRow = false;
+ $headers = array_map(fn ($h) => strtolower(trim($h)), $row);
+ $emailColumnIndex = array_search('email', $headers);
+
+ // If the first row looks like an email itself (no header), treat it as data
+ if ($emailColumnIndex === false && filter_var(trim($row[0]), FILTER_VALIDATE_EMAIL)) {
+ $emailColumnIndex = 0;
+ $emails[] = strtolower(trim($row[0]));
+ }
+
+ continue;
+ }
+
+ $value = trim($row[$emailColumnIndex] ?? '');
+
+ if (filter_var($value, FILTER_VALIDATE_EMAIL)) {
+ $emails[] = strtolower($value);
+ }
+ }
+
+ fclose($handle);
+
+ return array_unique($emails);
+ }
+}
diff --git a/app/Console/Commands/MatchUsersWithStripeCustomers.php b/app/Console/Commands/MatchUsersWithStripeCustomers.php
new file mode 100644
index 00000000..71ea2575
--- /dev/null
+++ b/app/Console/Commands/MatchUsersWithStripeCustomers.php
@@ -0,0 +1,110 @@
+count();
+
+ if ($totalUsers === 0) {
+ $this->info('No users found without Stripe IDs.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Found {$totalUsers} users without Stripe IDs.");
+
+ $limit = $this->option('limit');
+ if ($limit) {
+ $query->limit((int) $limit);
+ $this->info("Processing first {$limit} users...");
+ }
+
+ $dryRun = $this->option('dry-run');
+ if ($dryRun) {
+ $this->warn('DRY RUN MODE - No changes will be made');
+ }
+
+ $users = $query->get();
+
+ $matched = 0;
+ $notFound = 0;
+ $errors = 0;
+
+ $progressBar = $this->output->createProgressBar($users->count());
+ $progressBar->start();
+
+ /** @var User $user */
+ foreach ($users as $user) {
+ try {
+ /** @var Customer $customer */
+ $customer = $user->findStripeCustomerRecords()->first(fn (Customer $result) => $result->next_invoice_sequence === 1);
+
+ if ($customer) {
+ $matched++;
+
+ if (! $dryRun) {
+ $user->update(['stripe_id' => $customer->id]);
+ }
+
+ $this->newLine();
+ $this->line(" ✓ Matched: {$user->email} → {$customer->id}");
+ } else {
+ $notFound++;
+ $this->newLine();
+ $this->line(" - No match: {$user->email}");
+ }
+ } catch (ApiErrorException $e) {
+ $errors++;
+ $this->newLine();
+ $this->error(" ✗ Error for {$user->email}: {$e->getMessage()}");
+ } catch (\Exception $e) {
+ $errors++;
+ $this->newLine();
+ $this->error(" ✗ Unexpected error for {$user->email}: {$e->getMessage()}");
+ }
+
+ $progressBar->advance();
+
+ // Add a small delay to avoid rate limiting
+ Sleep::usleep(100000); // 0.1 seconds
+ }
+
+ $progressBar->finish();
+ $this->newLine(2);
+
+ // Summary
+ $this->info('Summary:');
+ $this->table(
+ ['Status', 'Count'],
+ [
+ ['Matched', $matched],
+ ['Not Found', $notFound],
+ ['Errors', $errors],
+ ['Total Processed', $users->count()],
+ ]
+ );
+
+ if ($dryRun) {
+ $this->warn('This was a dry run. Run without --dry-run to apply changes.');
+ }
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ProcessEligiblePayouts.php b/app/Console/Commands/ProcessEligiblePayouts.php
new file mode 100644
index 00000000..546ea0d9
--- /dev/null
+++ b/app/Console/Commands/ProcessEligiblePayouts.php
@@ -0,0 +1,35 @@
+where('eligible_for_payout_at', '<=', now())
+ ->get();
+
+ if ($eligiblePayouts->isEmpty()) {
+ $this->info('No eligible payouts to process.');
+
+ return self::SUCCESS;
+ }
+
+ foreach ($eligiblePayouts as $payout) {
+ ProcessPayoutTransfer::dispatch($payout);
+ }
+
+ $this->info("Dispatched {$eligiblePayouts->count()} payout transfer job(s).");
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/RemoveExpiredDiscordRoles.php b/app/Console/Commands/RemoveExpiredDiscordRoles.php
new file mode 100644
index 00000000..c1dbf304
--- /dev/null
+++ b/app/Console/Commands/RemoveExpiredDiscordRoles.php
@@ -0,0 +1,46 @@
+whereNotNull('discord_role_granted_at')
+ ->whereNotNull('discord_id')
+ ->get();
+
+ foreach ($users as $user) {
+ if (! $user->hasMaxAccess()) {
+ $success = $discord->removeMaxRole($user->discord_id);
+
+ if ($success) {
+ $user->update([
+ 'discord_role_granted_at' => null,
+ ]);
+
+ $this->info("Removed Discord role for user: {$user->email} ({$user->discord_username})");
+ $removed++;
+ } else {
+ $this->error("Failed to remove Discord role for user: {$user->email} ({$user->discord_username})");
+ }
+ }
+ }
+
+ $this->info("Total users with Discord role removed: {$removed}");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/RemoveExpiredGitHubAccess.php b/app/Console/Commands/RemoveExpiredGitHubAccess.php
new file mode 100644
index 00000000..5afbd1cc
--- /dev/null
+++ b/app/Console/Commands/RemoveExpiredGitHubAccess.php
@@ -0,0 +1,50 @@
+whereNotNull('mobile_repo_access_granted_at')
+ ->whereNotNull('github_username')
+ ->get();
+
+ foreach ($users as $user) {
+ // Check if user still qualifies for mobile repo access
+ if (! $user->hasMobileRepoAccess()) {
+ // Remove from repository
+ $success = $github->removeFromMobileRepo($user->github_username);
+
+ if ($success) {
+ // Clear the access timestamp
+ $user->update([
+ 'mobile_repo_access_granted_at' => null,
+ ]);
+
+ $this->info("Removed access for user: {$user->email} (@{$user->github_username})");
+ $removed++;
+ } else {
+ $this->error("Failed to remove access for user: {$user->email} (@{$user->github_username})");
+ }
+ }
+ }
+
+ $this->info("Total users with access removed: {$removed}");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/ResendLeadNotifications.php b/app/Console/Commands/ResendLeadNotifications.php
new file mode 100644
index 00000000..2af6e5c3
--- /dev/null
+++ b/app/Console/Commands/ResendLeadNotifications.php
@@ -0,0 +1,43 @@
+argument('date'))->startOfDay();
+
+ $leads = Lead::query()
+ ->where('created_at', '>=', $date)
+ ->orderBy('created_at')
+ ->get();
+
+ if ($leads->isEmpty()) {
+ $this->info('No leads found from '.$date->toDateString().' onwards.');
+
+ return;
+ }
+
+ $this->info("Found {$leads->count()} lead(s) from {$date->toDateString()} onwards.");
+
+ foreach ($leads as $lead) {
+ Notification::route('mail', 'sales@nativephp.com')
+ ->notify(new NewLeadSubmitted($lead));
+
+ $this->line("Sent notification for lead: {$lead->company} ({$lead->email})");
+ }
+
+ $this->info('Done.');
+ }
+}
diff --git a/app/Console/Commands/RetryFailedPayouts.php b/app/Console/Commands/RetryFailedPayouts.php
new file mode 100644
index 00000000..78ed1517
--- /dev/null
+++ b/app/Console/Commands/RetryFailedPayouts.php
@@ -0,0 +1,89 @@
+option('payout-id');
+
+ if ($payoutId) {
+ $payout = PluginPayout::find($payoutId);
+
+ if (! $payout) {
+ $this->error("Payout #{$payoutId} not found.");
+
+ return self::FAILURE;
+ }
+
+ if (! $payout->isFailed()) {
+ $this->error("Payout #{$payoutId} is not in failed status.");
+
+ return self::FAILURE;
+ }
+
+ return $this->retryPayout($payout, $stripeConnectService);
+ }
+
+ $failedPayouts = PluginPayout::failed()
+ ->with(['pluginLicense', 'developerAccount'])
+ ->get();
+
+ if ($failedPayouts->isEmpty()) {
+ $this->info('No failed payouts to retry.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Found {$failedPayouts->count()} failed payout(s) to retry.");
+
+ $succeeded = 0;
+ $failed = 0;
+
+ foreach ($failedPayouts as $payout) {
+ // Reset status to pending before retrying
+ $payout->update(['status' => PayoutStatus::Pending]);
+
+ if ($stripeConnectService->processTransfer($payout)) {
+ $this->info("Payout #{$payout->id} succeeded.");
+ $succeeded++;
+ } else {
+ $this->error("Payout #{$payout->id} failed again.");
+ $failed++;
+ }
+ }
+
+ $this->newLine();
+ $this->info("Results: {$succeeded} succeeded, {$failed} failed.");
+
+ return $failed > 0 ? self::FAILURE : self::SUCCESS;
+ }
+
+ protected function retryPayout(PluginPayout $payout, StripeConnectService $stripeConnectService): int
+ {
+ $this->info("Retrying payout #{$payout->id}...");
+
+ // Reset status to pending before retrying
+ $payout->update(['status' => PayoutStatus::Pending]);
+
+ if ($stripeConnectService->processTransfer($payout)) {
+ $this->info('Payout succeeded!');
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Payout failed again.');
+
+ return self::FAILURE;
+ }
+}
diff --git a/app/Console/Commands/SatisBuild.php b/app/Console/Commands/SatisBuild.php
new file mode 100644
index 00000000..d7b06650
--- /dev/null
+++ b/app/Console/Commands/SatisBuild.php
@@ -0,0 +1,77 @@
+option('plugin');
+
+ if ($pluginName) {
+ $plugin = Plugin::where('name', $pluginName)->first();
+
+ if (! $plugin) {
+ $this->error("Plugin '{$pluginName}' not found.");
+
+ return self::FAILURE;
+ }
+
+ if (! $plugin->isApproved()) {
+ $this->error("Plugin '{$pluginName}' is not approved.");
+
+ return self::FAILURE;
+ }
+
+ $this->info("Triggering Satis build for: {$pluginName}");
+ $result = $satisService->build([$plugin]);
+ } else {
+ $this->info('Triggering Satis build for all approved plugins...');
+ $result = $satisService->buildAll();
+ }
+
+ if ($result['success']) {
+ $this->info('Build triggered successfully!');
+ $this->line("Job ID: {$result['job_id']}");
+
+ if (isset($result['plugins_count'])) {
+ $this->line("Plugins: {$result['plugins_count']}");
+ }
+
+ return self::SUCCESS;
+ }
+
+ $this->error('Build trigger failed: '.$result['error']);
+
+ if (isset($result['status'])) {
+ $this->line("HTTP Status: {$result['status']}");
+ }
+
+ $this->line('API URL: '.config('services.satis.url'));
+ $this->line('API Key configured: '.(config('services.satis.api_key') ? 'Yes' : 'No'));
+
+ return self::FAILURE;
+ }
+}
diff --git a/app/Console/Commands/SendLegacyLicenseThankYou.php b/app/Console/Commands/SendLegacyLicenseThankYou.php
new file mode 100644
index 00000000..74c7b81b
--- /dev/null
+++ b/app/Console/Commands/SendLegacyLicenseThankYou.php
@@ -0,0 +1,83 @@
+option('dry-run');
+
+ if ($dryRun) {
+ $this->info('DRY RUN - No emails will be sent');
+ }
+
+ // Find all legacy licenses (no subscription, created before May 8, 2025)
+ // that have a user and haven't been converted to a subscription
+ $legacyLicenses = License::query()
+ ->whereNull('subscription_item_id')
+ ->where('created_at', '<', Date::create(2025, 5, 8))
+ ->whereHas('user')
+ ->with('user')
+ ->get();
+
+ $this->info("Found {$legacyLicenses->count()} legacy license(s)");
+
+ // Group by user to avoid sending multiple emails to the same person
+ $userLicenses = $legacyLicenses->groupBy('user_id');
+
+ $sent = 0;
+ $skipped = 0;
+
+ foreach ($userLicenses as $userId => $licenses) {
+ $user = $licenses->first()->user;
+
+ if (! $user) {
+ $this->warn("Skipping license(s) for user ID {$userId} - user not found");
+ $skipped++;
+
+ continue;
+ }
+
+ // Check if user has any active subscription (meaning they've already renewed)
+ $hasActiveSubscription = $user->subscriptions()
+ ->where('stripe_status', 'active')
+ ->exists();
+
+ if ($hasActiveSubscription) {
+ $this->line("Skipping {$user->email} - already has active subscription");
+ $skipped++;
+
+ continue;
+ }
+
+ // Use the first (or oldest) legacy license for the email
+ $license = $licenses->sortBy('created_at')->first();
+
+ if ($dryRun) {
+ $this->line("Would send to: {$user->email} (License: {$license->key})");
+ } else {
+ $user->notify(new LegacyLicenseThankYou($license));
+ $this->line("Sent to: {$user->email}");
+ }
+
+ $sent++;
+ }
+
+ $this->newLine();
+ $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
+ $this->info("Skipped: {$skipped} user(s)");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/SendLicenseExpiryWarnings.php b/app/Console/Commands/SendLicenseExpiryWarnings.php
new file mode 100644
index 00000000..e9241933
--- /dev/null
+++ b/app/Console/Commands/SendLicenseExpiryWarnings.php
@@ -0,0 +1,88 @@
+option('catch-up');
+
+ if ($catchUp) {
+ $this->info('Running in catch-up mode - sending missed warnings...');
+ }
+
+ foreach ($warningDays as $days) {
+ $sent = $this->sendWarningsForDays($days, $catchUp);
+ $totalSent += $sent;
+
+ $this->info("Sent {$sent} warning emails for licenses expiring in {$days} day(s)");
+ }
+
+ $this->info("Total warning emails sent: {$totalSent}");
+
+ return Command::SUCCESS;
+ }
+
+ private function sendWarningsForDays(int $days, bool $catchUp = false): int
+ {
+ $sent = 0;
+
+ $query = License::query()
+ ->whereNull('subscription_item_id') // Legacy licenses without subscriptions
+ ->with('user');
+
+ if ($catchUp) {
+ // Catch-up mode: find licenses that are within the warning window but haven't received this warning yet
+ // For 30-day: expires within 30 days (but more than 7 days to avoid overlap)
+ // For 7-day: expires within 7 days (but more than 1 day)
+ // For 1-day: expires within 1 day (but hasn't expired yet)
+ $warningThresholds = [30 => 7, 7 => 1, 1 => 0];
+ $lowerBound = $warningThresholds[$days] ?? 0;
+
+ $query->where('expires_at', '>', now()->addDays($lowerBound)->startOfDay())
+ ->where('expires_at', '<=', now()->addDays($days)->endOfDay())
+ ->whereDoesntHave('expiryWarnings', function ($q) use ($days): void {
+ $q->where('warning_days', $days);
+ });
+ } else {
+ // Normal mode: only licenses expiring on the exact target date
+ $targetDate = now()->addDays($days)->startOfDay();
+ $query->whereDate('expires_at', $targetDate)
+ ->whereDoesntHave('expiryWarnings', function ($q) use ($days): void {
+ $q->where('warning_days', $days)
+ ->where('sent_at', '>=', now()->subHours(23)); // Prevent duplicate emails within 23 hours
+ });
+ }
+
+ $licenses = $query->get();
+
+ foreach ($licenses as $license) {
+ if ($license->user) {
+ $license->user->notify(new LicenseExpiryWarning($license, $days));
+
+ // Track that we sent this warning
+ $license->expiryWarnings()->create([
+ 'warning_days' => $days,
+ 'sent_at' => now(),
+ ]);
+
+ $sent++;
+
+ $this->line("Sent {$days}-day warning to {$license->user->email} for license {$license->key}");
+ }
+ }
+
+ return $sent;
+ }
+}
diff --git a/app/Console/Commands/SendMaxToUltraAnnouncement.php b/app/Console/Commands/SendMaxToUltraAnnouncement.php
new file mode 100644
index 00000000..1515b5a9
--- /dev/null
+++ b/app/Console/Commands/SendMaxToUltraAnnouncement.php
@@ -0,0 +1,59 @@
+option('dry-run');
+
+ if ($dryRun) {
+ $this->info('DRY RUN - No emails will be sent');
+ }
+
+ $maxPriceIds = array_filter([
+ config('subscriptions.plans.max.stripe_price_id'),
+ config('subscriptions.plans.max.stripe_price_id_monthly'),
+ config('subscriptions.plans.max.stripe_price_id_eap'),
+ config('subscriptions.plans.max.stripe_price_id_discounted'),
+ ]);
+
+ $users = User::query()
+ ->whereHas('subscriptions', function ($query) use ($maxPriceIds) {
+ $query->where('stripe_status', 'active')
+ ->where('is_comped', false)
+ ->whereIn('stripe_price', $maxPriceIds);
+ })
+ ->get();
+
+ $this->info("Found {$users->count()} paying Max subscriber(s)");
+
+ $sent = 0;
+
+ foreach ($users as $user) {
+ if ($dryRun) {
+ $this->line("Would send to: {$user->email}");
+ } else {
+ $user->notify(new MaxToUltraAnnouncement);
+ $this->line("Sent to: {$user->email}");
+ }
+
+ $sent++;
+ }
+
+ $this->newLine();
+ $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/SendUltraLicenseHolderPromotion.php b/app/Console/Commands/SendUltraLicenseHolderPromotion.php
new file mode 100644
index 00000000..29a03243
--- /dev/null
+++ b/app/Console/Commands/SendUltraLicenseHolderPromotion.php
@@ -0,0 +1,78 @@
+option('dry-run');
+
+ if ($dryRun) {
+ $this->info('DRY RUN - No emails will be sent');
+ }
+
+ $legacyLicenses = License::query()
+ ->whereNull('subscription_item_id')
+ ->whereHas('user')
+ ->with('user')
+ ->get();
+
+ // Group by user to avoid sending multiple emails to the same person
+ $userLicenses = $legacyLicenses->groupBy('user_id');
+
+ $eligible = 0;
+ $skipped = 0;
+
+ foreach ($userLicenses as $userId => $licenses) {
+ $user = $licenses->first()->user;
+
+ if (! $user) {
+ $skipped++;
+
+ continue;
+ }
+
+ // Skip users who already have an active subscription
+ $hasActiveSubscription = $user->subscriptions()
+ ->where('stripe_status', 'active')
+ ->exists();
+
+ if ($hasActiveSubscription) {
+ $this->line("Skipping {$user->email} - already has active subscription");
+ $skipped++;
+
+ continue;
+ }
+
+ $license = $licenses->sortBy('created_at')->first();
+ $planName = Subscription::from($license->policy_name)->name();
+
+ if ($dryRun) {
+ $this->line("Would send to: {$user->email} ({$planName})");
+ } else {
+ $user->notify(new UltraLicenseHolderPromotion($planName));
+ $this->line("Sent to: {$user->email} ({$planName})");
+ }
+
+ $eligible++;
+ }
+
+ $this->newLine();
+ $this->info("Found {$eligible} eligible license holder(s)");
+ $this->info($dryRun ? "Would send: {$eligible} email(s)" : "Sent: {$eligible} email(s)");
+ $this->info("Skipped: {$skipped} user(s)");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/SendUltraUpgradePromotion.php b/app/Console/Commands/SendUltraUpgradePromotion.php
new file mode 100644
index 00000000..4b1fcbfb
--- /dev/null
+++ b/app/Console/Commands/SendUltraUpgradePromotion.php
@@ -0,0 +1,74 @@
+option('dry-run');
+
+ if ($dryRun) {
+ $this->info('DRY RUN - No emails will be sent');
+ }
+
+ $miniPriceIds = array_filter([
+ config('subscriptions.plans.mini.stripe_price_id'),
+ config('subscriptions.plans.mini.stripe_price_id_eap'),
+ ]);
+
+ $proPriceIds = array_filter([
+ config('subscriptions.plans.pro.stripe_price_id'),
+ config('subscriptions.plans.pro.stripe_price_id_eap'),
+ config('subscriptions.plans.pro.stripe_price_id_discounted'),
+ ]);
+
+ $eligiblePriceIds = array_merge($miniPriceIds, $proPriceIds);
+
+ $users = User::query()
+ ->whereHas('subscriptions', function ($query) use ($eligiblePriceIds) {
+ $query->where('stripe_status', 'active')
+ ->where('is_comped', false)
+ ->whereIn('stripe_price', $eligiblePriceIds);
+ })
+ ->get();
+
+ $this->info("Found {$users->count()} eligible subscriber(s)");
+
+ $sent = 0;
+
+ foreach ($users as $user) {
+ $priceId = $user->subscriptions()
+ ->where('stripe_status', 'active')
+ ->where('is_comped', false)
+ ->whereIn('stripe_price', $eligiblePriceIds)
+ ->value('stripe_price');
+
+ $planName = Subscription::fromStripePriceId($priceId)->name();
+
+ if ($dryRun) {
+ $this->line("Would send to: {$user->email} ({$planName})");
+ } else {
+ $user->notify(new UltraUpgradePromotion($planName));
+ $this->line("Sent to: {$user->email} ({$planName})");
+ }
+
+ $sent++;
+ }
+
+ $this->newLine();
+ $this->info($dryRun ? "Would send: {$sent} email(s)" : "Sent: {$sent} email(s)");
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/app/Console/Commands/SyncPlugins.php b/app/Console/Commands/SyncPlugins.php
new file mode 100644
index 00000000..8406da10
--- /dev/null
+++ b/app/Console/Commands/SyncPlugins.php
@@ -0,0 +1,35 @@
+count();
+
+ if ($count === 0) {
+ $this->info('No plugins to sync.');
+
+ return self::SUCCESS;
+ }
+
+ $this->info("Dispatching sync jobs for {$count} plugins...");
+
+ $plugins->each(fn (Plugin $plugin) => dispatch(new SyncPlugin($plugin)));
+
+ $this->info('Done. Jobs have been dispatched to the queue.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php
index e6b9960e..6600f5e4 100644
--- a/app/Console/Kernel.php
+++ b/app/Console/Kernel.php
@@ -12,7 +12,22 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
- // $schedule->command('inspire')->hourly();
+ // Remove GitHub access for users with expired Max licenses
+ $schedule->command('github:remove-expired-access')
+ ->dailyAt('10:00')
+ ->onOneServer()
+ ->runInBackground();
+
+ // Remove Discord Max role for users with expired Max licenses
+ $schedule->command('discord:remove-expired-roles')
+ ->dailyAt('10:30')
+ ->onOneServer()
+ ->runInBackground();
+
+ // Process developer payouts that have passed the 15-day holding period
+ $schedule->command('payouts:process-eligible')
+ ->dailyAt('11:00')
+ ->onOneServer();
}
/**
diff --git a/app/Enums/LicenseSource.php b/app/Enums/LicenseSource.php
new file mode 100644
index 00000000..9d1a7150
--- /dev/null
+++ b/app/Enums/LicenseSource.php
@@ -0,0 +1,11 @@
+ 'Pending',
+ self::Transferred => 'Transferred',
+ self::Failed => 'Failed',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Pending => 'yellow',
+ self::Transferred => 'green',
+ self::Failed => 'red',
+ };
+ }
+}
diff --git a/app/Enums/PluginActivityType.php b/app/Enums/PluginActivityType.php
new file mode 100644
index 00000000..56a26126
--- /dev/null
+++ b/app/Enums/PluginActivityType.php
@@ -0,0 +1,45 @@
+ 'Submitted',
+ self::Resubmitted => 'Resubmitted',
+ self::Approved => 'Approved',
+ self::Rejected => 'Rejected',
+ self::DescriptionUpdated => 'Description Updated',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Submitted => 'info',
+ self::Resubmitted => 'info',
+ self::Approved => 'success',
+ self::Rejected => 'danger',
+ self::DescriptionUpdated => 'gray',
+ };
+ }
+
+ public function icon(): string
+ {
+ return match ($this) {
+ self::Submitted => 'heroicon-o-paper-airplane',
+ self::Resubmitted => 'heroicon-o-arrow-path',
+ self::Approved => 'heroicon-o-check-circle',
+ self::Rejected => 'heroicon-o-x-circle',
+ self::DescriptionUpdated => 'heroicon-o-pencil-square',
+ };
+ }
+}
diff --git a/app/Enums/PluginStatus.php b/app/Enums/PluginStatus.php
new file mode 100644
index 00000000..4fd44020
--- /dev/null
+++ b/app/Enums/PluginStatus.php
@@ -0,0 +1,28 @@
+ 'Pending Review',
+ self::Approved => 'Approved',
+ self::Rejected => 'Rejected',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Pending => 'yellow',
+ self::Approved => 'green',
+ self::Rejected => 'red',
+ };
+ }
+}
diff --git a/app/Enums/PluginTier.php b/app/Enums/PluginTier.php
new file mode 100644
index 00000000..30b952ad
--- /dev/null
+++ b/app/Enums/PluginTier.php
@@ -0,0 +1,60 @@
+ 'Bronze',
+ self::Silver => 'Silver',
+ self::Gold => 'Gold',
+ };
+ }
+
+ public function description(): string
+ {
+ return match ($this) {
+ self::Bronze => 'Entry-level plugins',
+ self::Silver => 'Standard plugins',
+ self::Gold => 'Premium plugins',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Bronze => 'warning',
+ self::Silver => 'gray',
+ self::Gold => 'success',
+ };
+ }
+
+ /**
+ * Get the price amounts (in cents) for each PriceTier.
+ *
+ * @return array
+ */
+ public function getPrices(): array
+ {
+ return match ($this) {
+ self::Bronze => [
+ PriceTier::Regular->value => 2900,
+ PriceTier::Subscriber->value => 1100,
+ ],
+ self::Silver => [
+ PriceTier::Regular->value => 4900,
+ PriceTier::Subscriber->value => 1700,
+ ],
+ self::Gold => [
+ PriceTier::Regular->value => 9900,
+ PriceTier::Subscriber->value => 3100,
+ ],
+ };
+ }
+}
diff --git a/app/Enums/PluginType.php b/app/Enums/PluginType.php
new file mode 100644
index 00000000..27df3858
--- /dev/null
+++ b/app/Enums/PluginType.php
@@ -0,0 +1,17 @@
+ 'Free',
+ self::Paid => 'Paid',
+ };
+ }
+}
diff --git a/app/Enums/PriceTier.php b/app/Enums/PriceTier.php
new file mode 100644
index 00000000..b982fb42
--- /dev/null
+++ b/app/Enums/PriceTier.php
@@ -0,0 +1,42 @@
+ 'Regular',
+ self::Subscriber => 'Subscriber',
+ self::Eap => 'Early Access',
+ };
+ }
+
+ public function description(): string
+ {
+ return match ($this) {
+ self::Regular => 'Standard pricing for all customers',
+ self::Subscriber => 'Discounted pricing for subscribers',
+ self::Eap => 'Special pricing for Early Access Program customers',
+ };
+ }
+
+ /**
+ * Get the priority order for tier selection (lower = higher priority).
+ * When a user qualifies for multiple tiers, the lowest priced tier wins,
+ * but this priority helps with display/sorting.
+ */
+ public function priority(): int
+ {
+ return match ($this) {
+ self::Eap => 1,
+ self::Subscriber => 2,
+ self::Regular => 3,
+ };
+ }
+}
diff --git a/app/Enums/StripeConnectStatus.php b/app/Enums/StripeConnectStatus.php
new file mode 100644
index 00000000..8af1617d
--- /dev/null
+++ b/app/Enums/StripeConnectStatus.php
@@ -0,0 +1,33 @@
+ 'Pending Onboarding',
+ self::Active => 'Active',
+ self::Disabled => 'Disabled',
+ };
+ }
+
+ public function color(): string
+ {
+ return match ($this) {
+ self::Pending => 'yellow',
+ self::Active => 'green',
+ self::Disabled => 'red',
+ };
+ }
+
+ public function canReceivePayouts(): bool
+ {
+ return $this === self::Active;
+ }
+}
diff --git a/app/Enums/Subscription.php b/app/Enums/Subscription.php
new file mode 100644
index 00000000..760caff0
--- /dev/null
+++ b/app/Enums/Subscription.php
@@ -0,0 +1,132 @@
+items as $item) {
+ $priceId = $item->price->id;
+
+ if (self::isExtraSeatPrice($priceId)) {
+ continue;
+ }
+
+ return self::fromStripePriceId($priceId);
+ }
+
+ throw new RuntimeException('Could not resolve a plan price id from subscription items.');
+ }
+
+ public static function isExtraSeatPrice(string $priceId): bool
+ {
+ return in_array($priceId, array_filter([
+ config('subscriptions.plans.max.stripe_extra_seat_price_id'),
+ config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'),
+ ]));
+ }
+
+ public static function extraSeatStripePriceId(string $interval): ?string
+ {
+ return match ($interval) {
+ 'year' => config('subscriptions.plans.max.stripe_extra_seat_price_id'),
+ 'month' => config('subscriptions.plans.max.stripe_extra_seat_price_id_monthly'),
+ default => null,
+ };
+ }
+
+ public static function fromStripePriceId(string $priceId): self
+ {
+ return match ($priceId) {
+ config('subscriptions.plans.mini.stripe_price_id'),
+ config('subscriptions.plans.mini.stripe_price_id_eap') => self::Mini,
+ 'price_1RoZeVAyFo6rlwXqtnOViUCf',
+ config('subscriptions.plans.pro.stripe_price_id'),
+ config('subscriptions.plans.pro.stripe_price_id_discounted'),
+ config('subscriptions.plans.pro.stripe_price_id_eap') => self::Pro,
+ 'price_1RoZk0AyFo6rlwXqjkLj4hZ0',
+ config('subscriptions.plans.max.stripe_price_id'),
+ config('subscriptions.plans.max.stripe_price_id_monthly'),
+ config('subscriptions.plans.max.stripe_price_id_discounted'),
+ config('subscriptions.plans.max.stripe_price_id_eap'),
+ config('subscriptions.plans.max.stripe_price_id_comped') => self::Max,
+ default => throw new RuntimeException("Unknown Stripe price id: {$priceId}"),
+ };
+ }
+
+ public static function fromAnystackPolicy(string $policyId): self
+ {
+ return match ($policyId) {
+ config('subscriptions.plans.mini.anystack_policy_id') => self::Mini,
+ config('subscriptions.plans.pro.anystack_policy_id') => self::Pro,
+ config('subscriptions.plans.max.anystack_policy_id') => self::Max,
+ config('subscriptions.plans.forever.anystack_policy_id') => self::Forever,
+ config('subscriptions.plans.trial.anystack_policy_id') => self::Trial,
+ default => throw new RuntimeException("Unknown Anystack policy id: {$policyId}"),
+ };
+ }
+
+ public function name(): string
+ {
+ return config("subscriptions.plans.{$this->value}.name");
+ }
+
+ public function stripePriceId(bool $forceEap = false, bool $discounted = false, string $interval = 'year'): string
+ {
+ // Monthly billing uses the regular monthly price (no EAP/discounted monthly prices exist)
+ if ($interval === 'month') {
+ return config("subscriptions.plans.{$this->value}.stripe_price_id_monthly")
+ ?? config("subscriptions.plans.{$this->value}.stripe_price_id");
+ }
+
+ // EAP ends June 1st at midnight UTC
+ if (now()->isBefore('2025-06-01 00:00:00') || $forceEap) {
+ return config("subscriptions.plans.{$this->value}.stripe_price_id_eap");
+ }
+
+ if ($discounted) {
+ return config("subscriptions.plans.{$this->value}.stripe_price_id_discounted");
+ }
+
+ return config("subscriptions.plans.{$this->value}.stripe_price_id");
+ }
+
+ public function stripePaymentLink(): string
+ {
+ return config("subscriptions.plans.{$this->value}.stripe_payment_link");
+ }
+
+ public function anystackProductId(): string
+ {
+ return config("subscriptions.plans.{$this->value}.anystack_product_id");
+ }
+
+ public function anystackPolicyId(): string
+ {
+ return config("subscriptions.plans.{$this->value}.anystack_policy_id");
+ }
+
+ public function supportsSubLicenses(): bool
+ {
+ return in_array($this, [self::Pro, self::Max, self::Forever]);
+ }
+
+ public function subLicenseLimit(): ?int
+ {
+ return match ($this) {
+ self::Pro => 9,
+ self::Max, self::Forever => null, // Unlimited
+ default => 0,
+ };
+ }
+}
diff --git a/app/Enums/TeamUserRole.php b/app/Enums/TeamUserRole.php
new file mode 100644
index 00000000..95fa8f29
--- /dev/null
+++ b/app/Enums/TeamUserRole.php
@@ -0,0 +1,9 @@
+reportable(function (Throwable $e) {
- //
+ $this->reportable(function (Throwable $e): void {
+ Integration::captureUnhandledException($e);
});
}
}
diff --git a/app/Exceptions/InvalidStateException.php b/app/Exceptions/InvalidStateException.php
new file mode 100644
index 00000000..28ec36a0
--- /dev/null
+++ b/app/Exceptions/InvalidStateException.php
@@ -0,0 +1,10 @@
+
+