Implement Phase 1: Foundation & Core Setup

Complete foundation for the EZSCALE billing platform replacing WHMCS:

- Install Composer deps (Fortify, Passport, Cashier, PayPal, Spatie Permissions, Inertia)
- Install Vue 3 + Inertia.js with Vite, 3 layouts (App, Auth, Admin)
- Configure subdomain routing (marketing, account, admin) with domain-based route files
- Create 30 database migrations (15 custom tables + package defaults)
- Create 14 Eloquent models with relationships, factories, and encrypted casts
- Set up Fortify auth with 7 Vue pages (Login, Register, ForgotPassword, ResetPassword, VerifyEmail, ConfirmPassword, TwoFactorChallenge)
- Add 2FA TOTP setup page with QR code and recovery codes
- Configure middleware (Inertia, Spatie roles/permissions, EnsureUserNotSuspended)
- Create seeders for roles/permissions, sample plans, and admin user
- Build dashboard controllers and Vue pages for customer and admin panels
- Add 4 shared Vue components (Card, Button, NavLink, FlashMessages)
- Generate Passport OAuth2 keys for future SSO/API use
- Write 24 Pest tests (auth, role-based access, models) — all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 02:50:46 -05:00
parent cf7669f270
commit 26704f9721
130 changed files with 6862 additions and 230 deletions

View File

@@ -0,0 +1,124 @@
---
name: tailwindcss-development
description: >-
Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components,
working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors,
typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle,
hero section, cards, buttons, or any visual/UI changes.
---
# Tailwind CSS Development
## When to Apply
Activate this skill when:
- Adding styles to components or pages
- Working with responsive design
- Implementing dark mode
- Extracting repeated patterns into components
- Debugging spacing or layout issues
## Documentation
Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation.
## Basic Usage
- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns.
- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue).
- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically.
## Tailwind CSS v4 Specifics
- Always use Tailwind CSS v4 and avoid deprecated utilities.
- `corePlugins` is not supported in Tailwind v4.
### CSS-First Configuration
In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed:
<code-snippet name="CSS-First Config" lang="css">
@theme {
--color-brand: oklch(0.72 0.11 178);
}
</code-snippet>
### Import Syntax
In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3:
<code-snippet name="v4 Import Syntax" lang="diff">
- @tailwind base;
- @tailwind components;
- @tailwind utilities;
+ @import "tailwindcss";
</code-snippet>
### Replaced Utilities
Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric.
| Deprecated | Replacement |
|------------|-------------|
| bg-opacity-* | bg-black/* |
| text-opacity-* | text-black/* |
| border-opacity-* | border-black/* |
| divide-opacity-* | divide-black/* |
| ring-opacity-* | ring-black/* |
| placeholder-opacity-* | placeholder-black/* |
| flex-shrink-* | shrink-* |
| flex-grow-* | grow-* |
| overflow-ellipsis | text-ellipsis |
| decoration-slice | box-decoration-slice |
| decoration-clone | box-decoration-clone |
## Spacing
Use `gap` utilities instead of margins for spacing between siblings:
<code-snippet name="Gap Utilities" lang="html">
<div class="flex gap-8">
<div>Item 1</div>
<div>Item 2</div>
</div>
</code-snippet>
## Dark Mode
If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant:
<code-snippet name="Dark Mode" lang="html">
<div class="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
Content adapts to color scheme
</div>
</code-snippet>
## Common Patterns
### Flexbox Layout
<code-snippet name="Flexbox Layout" lang="html">
<div class="flex items-center justify-between gap-4">
<div>Left content</div>
<div>Right content</div>
</div>
</code-snippet>
### Grid Layout
<code-snippet name="Grid Layout" lang="html">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div>Card 1</div>
<div>Card 2</div>
<div>Card 3</div>
</div>
</code-snippet>
## Common Pitfalls
- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.)
- Using `@tailwind` directives instead of `@import "tailwindcss"`
- Trying to use `tailwind.config.js` instead of CSS `@theme` directive
- Using margins for spacing between siblings instead of gap utilities
- Forgetting to add dark mode variants when the project uses dark mode

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel APP_NAME="EZSCALE Billing"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://ezscale.dev
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -20,46 +20,70 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=mariadb DB_CONNECTION=mysql
DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
DB_PORT=3306 DB_PORT=3306
DB_DATABASE=ezscale DB_DATABASE=ezscale_billing
DB_USERNAME=root DB_USERNAME=ezscale
DB_PASSWORD= DB_PASSWORD=
SESSION_DRIVER=database SESSION_DRIVER=redis
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=.ezscale.dev
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
QUEUE_CONNECTION=database QUEUE_CONNECTION=redis
CACHE_STORE=database CACHE_STORE=redis
# CACHE_PREFIX= # CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1 REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null REDIS_PASSWORD=null
REDIS_PORT=6379 REDIS_PORT=6379
# Domain Routing
DOMAIN_MARKETING=ezscale.dev
DOMAIN_ACCOUNT=account.ezscale.dev
DOMAIN_ADMIN=admin.ezscale.dev
MAIL_MAILER=log MAIL_MAILER=log
MAIL_SCHEME=null MAIL_SCHEME=null
MAIL_HOST=127.0.0.1 MAIL_HOST=127.0.0.1
MAIL_PORT=2525 MAIL_PORT=2525
MAIL_USERNAME=null MAIL_USERNAME=null
MAIL_PASSWORD=null MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="noreply@ezscale.cloud"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID= # Stripe
AWS_SECRET_ACCESS_KEY= STRIPE_KEY=
AWS_DEFAULT_REGION=us-east-1 STRIPE_SECRET=
AWS_BUCKET= STRIPE_WEBHOOK_SECRET=
AWS_USE_PATH_STYLE_ENDPOINT=false
# PayPal
PAYPAL_MODE=sandbox
PAYPAL_SANDBOX_CLIENT_ID=
PAYPAL_SANDBOX_CLIENT_SECRET=
# Discord Admin Alerts
DISCORD_WEBHOOK_URL=
# Provisioning APIs
VIRTFUSION_API_URL=
VIRTFUSION_API_KEY=
PTERODACTYL_API_URL=
PTERODACTYL_API_KEY=
SYNERGYCP_API_URL=
SYNERGYCP_API_KEY=
ENHANCE_API_URL=
ENHANCE_API_KEY=
# SupportPal
SUPPORTPAL_API_URL=
SUPPORTPAL_API_KEY=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View File

@@ -9,20 +9,26 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
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. 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.3.11 - php - 8.3.6
- inertiajs/inertia-laravel (INERTIA) - v2
- laravel/cashier (CASHIER) - v16
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12 - laravel/framework (LARAVEL) - v12
- laravel/passport (PASSPORT) - v13
- laravel/prompts (PROMPTS) - v0 - laravel/prompts (PROMPTS) - v0
- laravel/mcp (MCP) - v0 - laravel/mcp (MCP) - v0
- laravel/pint (PINT) - v1 - laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1 - laravel/sail (SAIL) - v1
- pestphp/pest (PEST) - v4 - pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12 - phpunit/phpunit (PHPUNIT) - v12
- tailwindcss (TAILWINDCSS) - v4
## Skills Activation ## Skills Activation
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works. - `pest-testing` — Tests applications using the Pest 4 PHP framework. Activates when writing tests, creating unit or feature tests, adding assertions, testing Livewire components, browser testing, debugging test failures, working with datasets or mocking; or when the user mentions test, spec, TDD, expects, assertion, coverage, or needs to verify functionality works.
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
## Conventions ## Conventions
@@ -126,6 +132,22 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Add useful array shape type definitions when appropriate. - Add useful array shape type definitions when appropriate.
=== inertia-laravel/core rules ===
# Inertia
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in `resources/js/Pages` (unless specified in `vite.config.js`). Use `Inertia::render()` for server-side routing instead of Blade views.
- ALWAYS use `search-docs` tool for version-specific Inertia documentation and updated code examples.
=== inertia-laravel/v2 rules ===
# Inertia v2
- Use all Inertia features from v1 and v2. Check the documentation before making changes to ensure the correct approach.
- New features: deferred props, infinite scrolling (merging props + `WhenVisible`), lazy loading on scroll, polling, prefetching.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
=== laravel/core rules === === laravel/core rules ===
# Do Things the Laravel Way # Do Things the Laravel Way
@@ -222,4 +244,12 @@ protected function isAccessible(User $user, ?string $path = null): bool
- Do NOT delete tests without approval. - Do NOT delete tests without approval.
- CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples. - CRITICAL: ALWAYS use `search-docs` tool for version-specific Pest documentation and updated code examples.
- IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task. - IMPORTANT: Activate `pest-testing` every time you're working with a Pest or testing-related task.
=== tailwindcss/core rules ===
# Tailwind CSS
- Always use existing Tailwind conventions; check project patterns before adding new ones.
- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
</laravel-boost-guidelines> </laravel-boost-guidelines>

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules;
/** @param array<string, string> $input */
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique(User::class),
],
'password' => $this->passwordRules(),
])->validate();
return DB::transaction(function () use ($input): User {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
]);
$user->assignRole('customer');
$user->profile()->create([]);
return $user;
});
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Actions\Fortify;
use Illuminate\Validation\Rules\Password;
trait PasswordValidationRules
{
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
*/
protected function passwordRules(): array
{
return ['required', 'string', Password::default(), 'confirmed'];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
{
use PasswordValidationRules;
/**
* Validate and reset the user's forgotten password.
*
* @param array<string, string> $input
*/
public function reset(User $user, array $input): void
{
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\UpdatesUserPasswords;
class UpdateUserPassword implements UpdatesUserPasswords
{
use PasswordValidationRules;
/**
* Validate and update the user's password.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'current_password' => ['required', 'string', 'current_password:web'],
'password' => $this->passwordRules(),
], [
'current_password.current_password' => __('The provided password does not match your current password.'),
])->validateWithBag('updatePassword');
$user->forceFill([
'password' => Hash::make($input['password']),
])->save();
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Actions\Fortify;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
{
/**
* Validate and update the given user's profile information.
*
* @param array<string, string> $input
*/
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users')->ignore($user->id),
],
])->validateWithBag('updateProfileInformation');
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
$this->updateVerifiedUser($user, $input);
} else {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
])->save();
}
}
/**
* Update the given verified user's profile information.
*
* @param array<string, string> $input
*/
protected function updateVerifiedUser(User $user, array $input): void
{
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],
'email_verified_at' => null,
])->save();
$user->sendEmailVerificationNotification();
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function index(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'servicesCount' => $user->services()->count(),
'activeServicesCount' => $user->services()->where('status', 'active')->count(),
]);
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Account;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class ProfileController extends Controller
{
public function show(Request $request): Response
{
return Inertia::render('Profile/Show', [
'user' => $request->user()->load('profile'),
]);
}
public function update(Request $request): \Illuminate\Http\RedirectResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:20'],
'company' => ['nullable', 'string', 'max:255'],
]);
$request->user()->update($validated);
return back();
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Service;
use App\Models\User;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function index(): Response
{
return Inertia::render('Admin/Dashboard', [
'totalCustomers' => User::role('customer')->count(),
'totalServices' => Service::count(),
'activeServices' => Service::where('status', 'active')->count(),
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserNotSuspended
{
public function handle(Request $request, Closure $next): Response
{
if ($request->user()?->isSuspended()) {
abort(403, 'Your account has been suspended.');
}
return $next($request);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Http\Responses;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
use Symfony\Component\HttpFoundation\Response;
class LoginResponse implements LoginResponseContract
{
public function toResponse($request): Response
{
/** @var Request $request */
$user = $request->user();
if ($user && $user->hasRole('admin')) {
$url = 'https://'.config('app.domains.admin').'/dashboard';
return Inertia::location($url);
}
return redirect()->intended('/dashboard');
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Announcement extends Model
{
use HasFactory;
protected $fillable = [
'title',
'content',
'type',
'published_at',
'expires_at',
];
protected function casts(): array
{
return [
'published_at' => 'datetime',
'expires_at' => 'datetime',
];
}
public function isPublished(): bool
{
return $this->published_at !== null && $this->published_at->isPast();
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class AuditLog extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'admin_id',
'action',
'resource_type',
'resource_id',
'ip_address',
'user_agent',
'changes',
];
protected function casts(): array
{
return [
'changes' => 'array',
'resource_id' => 'integer',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class BandwidthUsage extends Model
{
use HasFactory;
protected $table = 'bandwidth_usage';
protected $fillable = [
'service_id',
'period_start',
'period_end',
'bytes_in',
'bytes_out',
'total_bytes',
'quota_bytes',
'overage_bytes',
'overage_charge',
'source',
];
protected function casts(): array
{
return [
'period_start' => 'datetime',
'period_end' => 'datetime',
'bytes_in' => 'integer',
'bytes_out' => 'integer',
'total_bytes' => 'integer',
'quota_bytes' => 'integer',
'overage_bytes' => 'integer',
'overage_charge' => 'decimal:2',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Coupon extends Model
{
use HasFactory;
protected $fillable = [
'code',
'type',
'value',
'currency',
'applies_to',
'max_uses',
'times_used',
'expires_at',
];
protected function casts(): array
{
return [
'value' => 'decimal:2',
'applies_to' => 'array',
'max_uses' => 'integer',
'times_used' => 'integer',
'expires_at' => 'datetime',
];
}
public function redemptions(): HasMany
{
return $this->hasMany(CouponRedemption::class);
}
public function isValid(): bool
{
if ($this->expires_at && $this->expires_at->isPast()) {
return false;
}
if ($this->max_uses !== null && $this->times_used >= $this->max_uses) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class CouponRedemption extends Model
{
use HasFactory;
protected $fillable = [
'coupon_id',
'user_id',
'subscription_id',
'discount_amount',
];
protected function casts(): array
{
return [
'discount_amount' => 'decimal:2',
];
}
public function coupon(): BelongsTo
{
return $this->belongsTo(Coupon::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Cashier\Subscription;
class Invoice extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'subscription_id',
'gateway',
'gateway_invoice_id',
'number',
'total',
'tax',
'currency',
'status',
'invoice_pdf',
'due_date',
'paid_at',
];
protected function casts(): array
{
return [
'total' => 'decimal:2',
'tax' => 'decimal:2',
'due_date' => 'datetime',
'paid_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
public function paymentTransactions(): HasMany
{
return $this->hasMany(PaymentTransaction::class);
}
}

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class InvoiceItem extends Model
{
use HasFactory;
protected $fillable = [
'invoice_id',
'description',
'amount',
'quantity',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'quantity' => 'integer',
];
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
}

View File

@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Laravel\Cashier\Subscription;
class PaymentTransaction extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'subscription_id',
'invoice_id',
'gateway',
'gateway_transaction_id',
'amount',
'currency',
'status',
'payment_method',
'description',
'metadata',
];
protected function casts(): array
{
return [
'amount' => 'decimal:2',
'metadata' => 'array',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function invoice(): BelongsTo
{
return $this->belongsTo(Invoice::class);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Plan extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
'service_type',
'price',
'currency',
'billing_cycle',
'stripe_price_id',
'paypal_plan_id',
'features',
'stock_quantity',
'status',
'sort_order',
];
protected function casts(): array
{
return [
'price' => 'decimal:2',
'features' => 'array',
'stock_quantity' => 'integer',
'sort_order' => 'integer',
];
}
public function services(): HasMany
{
return $this->hasMany(Service::class);
}
public function isAvailable(): bool
{
if ($this->status !== 'active') {
return false;
}
if ($this->stock_quantity !== null && $this->stock_quantity <= 0) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ProvisioningLog extends Model
{
use HasFactory;
protected $fillable = [
'service_id',
'user_id',
'action',
'platform',
'platform_response',
'status',
'error_message',
'admin_id',
];
protected function casts(): array
{
return [
'platform_response' => 'array',
];
}
public function service(): BelongsTo
{
return $this->belongsTo(Service::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function admin(): BelongsTo
{
return $this->belongsTo(User::class, 'admin_id');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Cashier\Subscription;
class Service extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'subscription_id',
'plan_id',
'service_type',
'platform',
'platform_service_id',
'status',
'ipv4_address',
'ipv6_address',
'hostname',
'domain',
'credentials',
'provisioned_at',
'suspended_at',
'terminated_at',
'auto_renew',
];
protected function casts(): array
{
return [
'credentials' => 'encrypted:array',
'provisioned_at' => 'datetime',
'suspended_at' => 'datetime',
'terminated_at' => 'datetime',
'auto_renew' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function subscription(): BelongsTo
{
return $this->belongsTo(Subscription::class);
}
public function plan(): BelongsTo
{
return $this->belongsTo(Plan::class);
}
public function provisioningLogs(): HasMany
{
return $this->hasMany(ProvisioningLog::class);
}
public function bandwidthUsage(): HasMany
{
return $this->hasMany(BandwidthUsage::class);
}
public function isActive(): bool
{
return $this->status === 'active';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class SupportTicket extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'supportpal_ticket_id',
'subject',
'status',
'priority',
'last_reply_at',
];
protected function casts(): array
{
return [
'supportpal_ticket_id' => 'integer',
'last_reply_at' => 'datetime',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -1,48 +1,106 @@
<?php <?php
declare(strict_types=1);
namespace App\Models; namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Passport\HasApiTokens;
use Spatie\Permission\Traits\HasRoles;
class User extends Authenticatable class User extends Authenticatable implements MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory, Notifiable; use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
/** /** @var list<string> */
* The attributes that are mass assignable.
*
* @var list<string>
*/
protected $fillable = [ protected $fillable = [
'name', 'name',
'email', 'email',
'password', 'password',
'status',
'phone',
'company',
]; ];
/** /** @var list<string> */
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
protected $hidden = [ protected $hidden = [
'password', 'password',
'remember_token', 'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
]; ];
/** /** @return array<string, string> */
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array protected function casts(): array
{ {
return [ return [
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'passkey_credentials' => 'json',
]; ];
} }
public function profile(): HasOne
{
return $this->hasOne(UserProfile::class);
}
public function services(): HasMany
{
return $this->hasMany(Service::class);
}
/** @return HasMany<\App\Models\Invoice, $this> */
public function invoices(): HasMany
{
return $this->hasMany(Invoice::class);
}
public function paymentTransactions(): HasMany
{
return $this->hasMany(PaymentTransaction::class);
}
public function auditLogs(): HasMany
{
return $this->hasMany(AuditLog::class);
}
public function supportTickets(): HasMany
{
return $this->hasMany(SupportTicket::class);
}
public function couponRedemptions(): HasMany
{
return $this->hasMany(CouponRedemption::class);
}
public function isAdmin(): bool
{
return $this->hasRole('admin');
}
public function isCustomer(): bool
{
return $this->hasRole('customer');
}
public function isSuspended(): bool
{
return $this->status === 'suspended';
}
public function isBanned(): bool
{
return $this->status === 'banned';
}
} }

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserProfile extends Model
{
use HasFactory;
protected $fillable = [
'user_id',
'billing_address_line1',
'billing_address_line2',
'billing_city',
'billing_state',
'billing_zip',
'billing_country',
'shipping_address_line1',
'shipping_address_line2',
'shipping_city',
'shipping_state',
'shipping_zip',
'shipping_country',
'tax_id',
'tax_exempt',
'company_name',
'company_vat',
'notes',
];
protected function casts(): array
{
return [
'tax_exempt' => 'boolean',
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@@ -1,24 +1,36 @@
<?php <?php
declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Http\Responses\LoginResponse;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
/**
* Register any application services.
*/
public function register(): void public function register(): void
{ {
// $this->app->singleton(LoginResponseContract::class, LoginResponse::class);
} }
/**
* Bootstrap any application services.
*/
public function boot(): void public function boot(): void
{ {
// if ($this->app->environment('production', 'local')) {
URL::forceScheme('https');
}
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('register', function (Request $request) {
return Limit::perMinute(3)->by($request->ip());
});
} }
} }

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace App\Providers;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use App\Actions\Fortify\UpdateUserPassword;
use App\Actions\Fortify\UpdateUserProfileInformation;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Fortify::createUsersUsing(CreateNewUser::class);
Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);
Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
Fortify::loginView(fn () => Inertia::render('Auth/Login'));
Fortify::registerView(fn () => Inertia::render('Auth/Register'));
Fortify::requestPasswordResetLinkView(fn () => Inertia::render('Auth/ForgotPassword'));
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('Auth/ResetPassword', [
'token' => $request->route('token'),
'email' => $request->query('email'),
]));
Fortify::verifyEmailView(fn () => Inertia::render('Auth/VerifyEmail'));
Fortify::confirmPasswordView(fn () => Inertia::render('Auth/ConfirmPassword'));
Fortify::twoFactorChallengeView(fn () => Inertia::render('Auth/TwoFactorChallenge'));
RateLimiter::for('login', function (Request $request) {
$throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip());
return Limit::perMinute(5)->by($throttleKey);
});
RateLimiter::for('two-factor', function (Request $request) {
return Limit::perMinute(5)->by($request->session()->get('login.id'));
});
}
}

View File

@@ -7,6 +7,7 @@
"mcp": true, "mcp": true,
"sail": false, "sail": false,
"skills": [ "skills": [
"pest-testing" "pest-testing",
"tailwindcss-development"
] ]
} }

View File

@@ -3,15 +3,41 @@
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
return Application::configure(basePath: dirname(__DIR__)) return Application::configure(basePath: dirname(__DIR__))
->withRouting( ->withRouting(
web: __DIR__.'/../routes/web.php', web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php', commands: __DIR__.'/../routes/console.php',
health: '/up', health: '/up',
then: function (): void {
Route::domain(config('app.domains.marketing'))
->middleware('web')
->group(base_path('routes/marketing.php'));
Route::domain(config('app.domains.account'))
->middleware(['web', 'auth', 'verified'])
->group(base_path('routes/account.php'));
Route::domain(config('app.domains.admin'))
->middleware(['web', 'auth', 'verified', 'role:admin'])
->group(base_path('routes/admin.php'));
},
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
// $middleware->trustProxies(at: '*');
$middleware->web(append: [
\Inertia\Middleware::class,
]);
$middleware->alias([
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
'role_or_permission' => \Spatie\Permission\Middleware\RoleOrPermissionMiddleware::class,
'ensure_not_suspended' => \App\Http\Middleware\EnsureUserNotSuspended::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
// //

0
website/bootstrap/cache/.gitignore vendored Normal file → Executable file
View File

View File

@@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
]; ];

View File

@@ -10,8 +10,14 @@
"license": "MIT", "license": "MIT",
"require": { "require": {
"php": "^8.2", "php": "^8.2",
"inertiajs/inertia-laravel": "^2.0",
"laravel/cashier": "^16.2",
"laravel/fortify": "^1.34",
"laravel/framework": "^12.0", "laravel/framework": "^12.0",
"laravel/tinker": "^2.10.1" "laravel/passport": "^13.4",
"laravel/tinker": "^2.10.1",
"spatie/laravel-permission": "^6.24",
"srmklive/paypal": "^3.0"
}, },
"require-dev": { "require-dev": {
"fakerphp/faker": "^1.23", "fakerphp/faker": "^1.23",
@@ -89,4 +95,4 @@
}, },
"minimum-stability": "stable", "minimum-stability": "stable",
"prefer-stable": true "prefer-stable": true
} }

1774
website/composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -123,4 +123,20 @@ return [
'store' => env('APP_MAINTENANCE_STORE', 'database'), 'store' => env('APP_MAINTENANCE_STORE', 'database'),
], ],
/*
|--------------------------------------------------------------------------
| Domain Routing
|--------------------------------------------------------------------------
|
| These values define the domains for the multi-domain routing setup.
| Each subdomain serves a different section of the application.
|
*/
'domains' => [
'marketing' => env('DOMAIN_MARKETING', 'ezscale.dev'),
'account' => env('DOMAIN_ACCOUNT', 'account.ezscale.dev'),
'admin' => env('DOMAIN_ADMIN', 'admin.ezscale.dev'),
],
]; ];

View File

@@ -40,6 +40,11 @@ return [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
], ],
/* /*

127
website/config/cashier.php Normal file
View File

@@ -0,0 +1,127 @@
<?php
use Laravel\Cashier\Console\WebhookCommand;
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
return [
/*
|--------------------------------------------------------------------------
| Stripe Keys
|--------------------------------------------------------------------------
|
| The Stripe publishable key and secret key give you access to Stripe's
| API. The "publishable" key is typically used when interacting with
| Stripe.js while the "secret" key accesses private API endpoints.
|
*/
'key' => env('STRIPE_KEY'),
'secret' => env('STRIPE_SECRET'),
/*
|--------------------------------------------------------------------------
| Cashier Path
|--------------------------------------------------------------------------
|
| This is the base URI path where Cashier's views, such as the payment
| verification screen, will be available from. You're free to tweak
| this path according to your preferences and application design.
|
*/
'path' => env('CASHIER_PATH', 'stripe'),
/*
|--------------------------------------------------------------------------
| Stripe Webhooks
|--------------------------------------------------------------------------
|
| Your Stripe webhook secret is used to prevent unauthorized requests to
| your Stripe webhook handling controllers. The tolerance setting will
| check the drift between the current time and the signed request's.
|
*/
'webhook' => [
'secret' => env('STRIPE_WEBHOOK_SECRET'),
'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300),
'events' => WebhookCommand::DEFAULT_EVENTS,
],
/*
|--------------------------------------------------------------------------
| Currency
|--------------------------------------------------------------------------
|
| This is the default currency that will be used when generating charges
| from your application. Of course, you are welcome to use any of the
| various world currencies that are currently supported via Stripe.
|
*/
'currency' => env('CASHIER_CURRENCY', 'usd'),
/*
|--------------------------------------------------------------------------
| Currency Locale
|--------------------------------------------------------------------------
|
| This is the default locale in which your money values are formatted in
| for display. To utilize other locales besides the default en locale
| verify you have the "intl" PHP extension installed on the system.
|
*/
'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'),
/*
|--------------------------------------------------------------------------
| Payment Confirmation Notification
|--------------------------------------------------------------------------
|
| If this setting is enabled, Cashier will automatically notify customers
| whose payments require additional verification. You should listen to
| Stripe's webhooks in order for this feature to function correctly.
|
*/
'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'),
/*
|--------------------------------------------------------------------------
| Invoice Settings
|--------------------------------------------------------------------------
|
| The following options determine how Cashier invoices are converted from
| HTML into PDFs. You're free to change the options based on the needs
| of your application or your preferences regarding invoice styling.
|
*/
'invoices' => [
'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class),
'options' => [
// Supported: 'letter', 'legal', 'A4'
'paper' => env('CASHIER_PAPER', 'letter'),
'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false),
],
],
/*
|--------------------------------------------------------------------------
| Stripe Logger
|--------------------------------------------------------------------------
|
| This setting defines which logging channel will be used by the Stripe
| library to write log messages. You are free to specify any of your
| logging channels listed inside the "logging" configuration file.
|
*/
'logger' => env('CASHIER_LOGGER'),
];

159
website/config/fortify.php Normal file
View File

@@ -0,0 +1,159 @@
<?php
use Laravel\Fortify\Features;
return [
/*
|--------------------------------------------------------------------------
| Fortify Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Fortify will use while
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Fortify Password Broker
|--------------------------------------------------------------------------
|
| Here you may specify which password broker Fortify can use when a user
| is resetting their password. This configured value should match one
| of your password brokers setup in your "auth" configuration file.
|
*/
'passwords' => 'users',
/*
|--------------------------------------------------------------------------
| Username / Email
|--------------------------------------------------------------------------
|
| This value defines which model attribute should be considered as your
| application's "username" field. Typically, this might be the email
| address of the users but you are free to change this value here.
|
| Out of the box, Fortify expects forgot password and reset password
| requests to have a field named 'email'. If the application uses
| another name for the field you may define it below as needed.
|
*/
'username' => 'email',
'email' => 'email',
/*
|--------------------------------------------------------------------------
| Lowercase Usernames
|--------------------------------------------------------------------------
|
| This value defines whether usernames should be lowercased before saving
| them in the database, as some database system string fields are case
| sensitive. You may disable this for your application if necessary.
|
*/
'lowercase_usernames' => true,
/*
|--------------------------------------------------------------------------
| Home Path
|--------------------------------------------------------------------------
|
| Here you may configure the path where users will get redirected during
| authentication or password reset when the operations are successful
| and the user is authenticated. You are free to change this value.
|
*/
'home' => '/dashboard',
/*
|--------------------------------------------------------------------------
| Fortify Routes Prefix / Subdomain
|--------------------------------------------------------------------------
|
| Here you may specify which prefix Fortify will assign to all the routes
| that it registers with the application. If necessary, you may change
| subdomain under which all of the Fortify routes will be available.
|
*/
'prefix' => '',
'domain' => env('FORTIFY_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Fortify Routes Middleware
|--------------------------------------------------------------------------
|
| Here you may specify which middleware Fortify will assign to the routes
| that it registers with the application. If necessary, you may change
| these middleware but typically this provided default is preferred.
|
*/
'middleware' => ['web'],
/*
|--------------------------------------------------------------------------
| Rate Limiting
|--------------------------------------------------------------------------
|
| By default, Fortify will throttle logins to five requests per minute for
| every email and IP address combination. However, if you would like to
| specify a custom rate limiter to call then you may specify it here.
|
*/
'limiters' => [
'login' => 'login',
'two-factor' => 'two-factor',
],
/*
|--------------------------------------------------------------------------
| Register View Routes
|--------------------------------------------------------------------------
|
| Here you may specify if the routes returning views should be disabled as
| you may not need them when building your own application. This may be
| especially true if you're writing a custom single-page application.
|
*/
'views' => true,
/*
|--------------------------------------------------------------------------
| Features
|--------------------------------------------------------------------------
|
| Some of the Fortify features are optional. You may disable the features
| by removing them from this array. You're free to only remove some of
| these features or you can even remove all of these if you need to.
|
*/
'features' => [
Features::registration(),
Features::resetPasswords(),
Features::emailVerification(),
Features::updateProfileInformation(),
Features::updatePasswords(),
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
// 'window' => 0,
]),
],
];

View File

@@ -0,0 +1,46 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];

26
website/config/paypal.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
/**
* PayPal Setting & API Credentials
* Created by Raza Mehdi <srmk@outlook.com>.
*/
return [
'mode' => env('PAYPAL_MODE', 'sandbox'), // Can only be 'sandbox' Or 'live'. If empty or invalid, 'live' will be used.
'sandbox' => [
'client_id' => env('PAYPAL_SANDBOX_CLIENT_ID', ''),
'client_secret' => env('PAYPAL_SANDBOX_CLIENT_SECRET', ''),
'app_id' => 'APP-80W284485P519543T',
],
'live' => [
'client_id' => env('PAYPAL_LIVE_CLIENT_ID', ''),
'client_secret' => env('PAYPAL_LIVE_CLIENT_SECRET', ''),
'app_id' => env('PAYPAL_LIVE_APP_ID', ''),
],
'payment_action' => env('PAYPAL_PAYMENT_ACTION', 'Sale'), // Can only be 'Sale', 'Authorization' or 'Order'
'currency' => env('PAYPAL_CURRENCY', 'USD'),
'notify_url' => env('PAYPAL_NOTIFY_URL', ''), // Change this accordingly for your application.
'locale' => env('PAYPAL_LOCALE', 'en_US'), // force gateway language i.e. it_IT, es_ES, en_US ... (for express checkout only)
'validate_ssl' => env('PAYPAL_VALIDATE_SSL', true), // Validate SSL when creating api client.
];

View File

@@ -0,0 +1,202 @@
<?php
return [
'models' => [
/*
* When using the "HasPermissions" trait from this package, we need to know which
* Eloquent model should be used to retrieve your permissions. Of course, it
* is often just the "Permission" model but you may use whatever you like.
*
* The model you want to use as a Permission model needs to implement the
* `Spatie\Permission\Contracts\Permission` contract.
*/
'permission' => Spatie\Permission\Models\Permission::class,
/*
* When using the "HasRoles" trait from this package, we need to know which
* Eloquent model should be used to retrieve your roles. Of course, it
* is often just the "Role" model but you may use whatever you like.
*
* The model you want to use as a Role model needs to implement the
* `Spatie\Permission\Contracts\Role` contract.
*/
'role' => Spatie\Permission\Models\Role::class,
],
'table_names' => [
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'roles' => 'roles',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your permissions. We have chosen a basic
* default value but you may easily change it to any table you like.
*/
'permissions' => 'permissions',
/*
* When using the "HasPermissions" trait from this package, we need to know which
* table should be used to retrieve your models permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_permissions' => 'model_has_permissions',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your models roles. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'model_has_roles' => 'model_has_roles',
/*
* When using the "HasRoles" trait from this package, we need to know which
* table should be used to retrieve your roles permissions. We have chosen a
* basic default value but you may easily change it to any table you like.
*/
'role_has_permissions' => 'role_has_permissions',
],
'column_names' => [
/*
* Change this if you want to name the related pivots other than defaults
*/
'role_pivot_key' => null, // default 'role_id',
'permission_pivot_key' => null, // default 'permission_id',
/*
* Change this if you want to name the related model primary key other than
* `model_id`.
*
* For example, this would be nice if your primary keys are all UUIDs. In
* that case, name this `model_uuid`.
*/
'model_morph_key' => 'model_id',
/*
* Change this if you want to use the teams feature and your related model's
* foreign key is other than `team_id`.
*/
'team_foreign_key' => 'team_id',
],
/*
* When set to true, the method for checking permissions will be registered on the gate.
* Set this to false if you want to implement custom logic for checking permissions.
*/
'register_permission_check_method' => true,
/*
* When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered
* this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated
* NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it.
*/
'register_octane_reset_listener' => false,
/*
* Events will fire when a role or permission is assigned/unassigned:
* \Spatie\Permission\Events\RoleAttached
* \Spatie\Permission\Events\RoleDetached
* \Spatie\Permission\Events\PermissionAttached
* \Spatie\Permission\Events\PermissionDetached
*
* To enable, set to true, and then create listeners to watch these events.
*/
'events_enabled' => false,
/*
* Teams Feature.
* When set to true the package implements teams using the 'team_foreign_key'.
* If you want the migrations to register the 'team_foreign_key', you must
* set this to true before doing the migration.
* If you already did the migration then you must make a new migration to also
* add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions'
* (view the latest version of this package's migration file)
*/
'teams' => false,
/*
* The class to use to resolve the permissions team id
*/
'team_resolver' => \Spatie\Permission\DefaultTeamResolver::class,
/*
* Passport Client Credentials Grant
* When set to true the package will use Passports Client to check permissions
*/
'use_passport_client_credentials' => false,
/*
* When set to true, the required permission names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_permission_in_exception' => false,
/*
* When set to true, the required role names are added to exception messages.
* This could be considered an information leak in some contexts, so the default
* setting is false here for optimum safety.
*/
'display_role_in_exception' => false,
/*
* By default wildcard permission lookups are disabled.
* See documentation to understand supported syntax.
*/
'enable_wildcard_permission' => false,
/*
* The class to use for interpreting wildcard permissions.
* If you need to modify delimiters, override the class and specify its name here.
*/
// 'wildcard_permission' => Spatie\Permission\WildcardPermission::class,
/* Cache-specific settings */
'cache' => [
/*
* By default all permissions are cached for 24 hours to speed up performance.
* When permissions or roles are updated the cache is flushed automatically.
*/
'expiration_time' => \DateInterval::createFromDateString('24 hours'),
/*
* The cache key used to store all permissions.
*/
'key' => 'spatie.permission.cache',
/*
* You may optionally indicate a specific cache driver to use for permission and
* role caching using any of the `store` drivers listed in the cache.php config
* file. Using 'default' here means to use the `default` set in cache.php.
*/
'store' => 'default',
],
];

View File

@@ -103,7 +103,7 @@ return [
*/ */
'batching' => [ 'batching' => [
'database' => env('DB_CONNECTION', 'sqlite'), 'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'job_batches', 'table' => 'job_batches',
], ],
@@ -122,7 +122,7 @@ return [
'failed' => [ 'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'sqlite'), 'database' => env('DB_CONNECTION', 'mysql'),
'table' => 'failed_jobs', 'table' => 'failed_jobs',
], ],

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Announcement> */
class AnnouncementFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'title' => fake()->sentence(),
'content' => fake()->paragraphs(2, true),
'type' => fake()->randomElement(['maintenance', 'feature', 'outage']),
'published_at' => now(),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\AuditLog> */
class AuditLogFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'action' => fake()->randomElement(['login', 'logout', 'service_provisioned', 'payment_failed']),
'ip_address' => fake()->ipv4(),
'user_agent' => fake()->userAgent(),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Coupon> */
class CouponFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'code' => Str::upper(fake()->unique()->bothify('??##??')),
'type' => fake()->randomElement(['percentage', 'fixed_amount']),
'value' => fake()->randomFloat(2, 5, 50),
'max_uses' => fake()->optional()->numberBetween(10, 1000),
'times_used' => 0,
'expires_at' => fake()->optional()->dateTimeBetween('now', '+1 year'),
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Invoice> */
class InvoiceFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'gateway' => 'stripe',
'number' => 'INV-'.fake()->unique()->numerify('######'),
'total' => fake()->randomFloat(2, 5, 500),
'tax' => fake()->randomFloat(2, 0, 50),
'currency' => 'USD',
'status' => fake()->randomElement(['draft', 'sent', 'paid', 'overdue']),
'due_date' => fake()->dateTimeBetween('now', '+30 days'),
];
}
public function paid(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'paid',
'paid_at' => now(),
]);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Plan> */
class PlanFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
$name = fake()->unique()->words(2, true);
return [
'name' => ucwords($name),
'slug' => Str::slug($name),
'description' => fake()->sentence(),
'service_type' => fake()->randomElement(['vps', 'dedicated', 'hosting', 'game_server']),
'price' => fake()->randomFloat(2, 5, 500),
'currency' => 'USD',
'billing_cycle' => fake()->randomElement(['monthly', 'quarterly', 'annual']),
'features' => [
'cpu' => fake()->numberBetween(1, 16).' vCPU',
'ram' => fake()->randomElement(['1GB', '2GB', '4GB', '8GB', '16GB', '32GB']),
'disk' => fake()->randomElement(['20GB', '50GB', '100GB', '200GB', '500GB']),
'bandwidth' => fake()->randomElement(['1TB', '2TB', '5TB', '10TB', 'Unlimited']),
],
'status' => 'active',
'sort_order' => fake()->numberBetween(0, 100),
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Service> */
class ServiceFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
$serviceType = fake()->randomElement(['vps', 'dedicated', 'hosting', 'game_server']);
$platforms = [
'vps' => 'virtfusion',
'dedicated' => 'synergycp',
'hosting' => 'enhance',
'game_server' => 'pterodactyl',
];
return [
'user_id' => User::factory(),
'plan_id' => Plan::factory(),
'service_type' => $serviceType,
'platform' => $platforms[$serviceType],
'platform_service_id' => (string) fake()->numberBetween(1000, 99999),
'status' => 'active',
'ipv4_address' => fake()->ipv4(),
'hostname' => fake()->domainName(),
'provisioned_at' => now(),
'auto_renew' => true,
];
}
public function pending(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'pending',
'provisioned_at' => null,
]);
}
public function suspended(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'suspended',
'suspended_at' => now(),
]);
}
}

View File

@@ -1,5 +1,7 @@
<?php <?php
declare(strict_types=1);
namespace Database\Factories; namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
@@ -11,16 +13,9 @@ use Illuminate\Support\Str;
*/ */
class UserFactory extends Factory class UserFactory extends Factory
{ {
/**
* The current password being used by the factory.
*/
protected static ?string $password; protected static ?string $password;
/** /** @return array<string, mixed> */
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array public function definition(): array
{ {
return [ return [
@@ -29,16 +24,44 @@ class UserFactory extends Factory
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),
'status' => 'active',
'phone' => fake()->optional()->phoneNumber(),
'company' => fake()->optional()->company(),
]; ];
} }
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static public function unverified(): static
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes): array => [
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }
public function admin(): static
{
return $this->afterCreating(function ($user): void {
$user->assignRole('admin');
});
}
public function customer(): static
{
return $this->afterCreating(function ($user): void {
$user->assignRole('customer');
});
}
public function suspended(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'suspended',
]);
}
public function banned(): static
{
return $this->state(fn (array $attributes): array => [
'status' => 'banned',
]);
}
} }

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\UserProfile> */
class UserProfileFactory extends Factory
{
/** @return array<string, mixed> */
public function definition(): array
{
return [
'user_id' => User::factory(),
'billing_address_line1' => fake()->streetAddress(),
'billing_city' => fake()->city(),
'billing_state' => fake()->stateAbbr(),
'billing_zip' => fake()->postcode(),
'billing_country' => fake()->countryCode(),
'tax_exempt' => false,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->text('two_factor_secret')
->after('password')
->nullable();
$table->text('two_factor_recovery_codes')
->after('two_factor_secret')
->nullable();
$table->timestamp('two_factor_confirmed_at')
->after('two_factor_recovery_codes')
->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn([
'two_factor_secret',
'two_factor_recovery_codes',
'two_factor_confirmed_at',
]);
});
}
};

View File

@@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,134 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
$teams = config('permission.teams');
$tableNames = config('permission.table_names');
$columnNames = config('permission.column_names');
$pivotRole = $columnNames['role_pivot_key'] ?? 'role_id';
$pivotPermission = $columnNames['permission_pivot_key'] ?? 'permission_id';
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not loaded. Run [php artisan config:clear] and try again.');
throw_if($teams && empty($columnNames['team_foreign_key'] ?? null), Exception::class, 'Error: team_foreign_key on config/permission.php not loaded. Run [php artisan config:clear] and try again.');
Schema::create($tableNames['permissions'], static function (Blueprint $table) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // permission id
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
$table->unique(['name', 'guard_name']);
});
Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames) {
// $table->engine('InnoDB');
$table->bigIncrements('id'); // role id
if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing
$table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable();
$table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index');
}
$table->string('name'); // For MyISAM use string('name', 225); // (or 166 for InnoDB with Redundant/Compact row format)
$table->string('guard_name'); // For MyISAM use string('guard_name', 25);
$table->timestamps();
if ($teams || config('permission.testing')) {
$table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']);
} else {
$table->unique(['name', 'guard_name']);
}
});
Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams) {
$table->unsignedBigInteger($pivotPermission);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index');
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
} else {
$table->primary([$pivotPermission, $columnNames['model_morph_key'], 'model_type'],
'model_has_permissions_permission_model_type_primary');
}
});
Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams) {
$table->unsignedBigInteger($pivotRole);
$table->string('model_type');
$table->unsignedBigInteger($columnNames['model_morph_key']);
$table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
if ($teams) {
$table->unsignedBigInteger($columnNames['team_foreign_key']);
$table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index');
$table->primary([$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
} else {
$table->primary([$pivotRole, $columnNames['model_morph_key'], 'model_type'],
'model_has_roles_role_model_type_primary');
}
});
Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission) {
$table->unsignedBigInteger($pivotPermission);
$table->unsignedBigInteger($pivotRole);
$table->foreign($pivotPermission)
->references('id') // permission id
->on($tableNames['permissions'])
->onDelete('cascade');
$table->foreign($pivotRole)
->references('id') // role id
->on($tableNames['roles'])
->onDelete('cascade');
$table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary');
});
app('cache')
->store(config('permission.cache.store') != 'default' ? config('permission.cache.store') : null)
->forget(config('permission.cache.key'));
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$tableNames = config('permission.table_names');
throw_if(empty($tableNames), Exception::class, 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.');
Schema::drop($tableNames['role_has_permissions']);
Schema::drop($tableNames['model_has_roles']);
Schema::drop($tableNames['model_has_permissions']);
Schema::drop($tableNames['roles']);
Schema::drop($tableNames['permissions']);
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignId('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_id')->nullable()->after('stripe_price');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_event_name')->nullable()->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_event_name');
});
}
};

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->string('status')->default('active')->after('password');
$table->string('phone')->nullable()->after('status');
$table->string('company')->nullable()->after('phone');
$table->json('passkey_credentials')->nullable()->after('two_factor_recovery_codes');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn(['status', 'phone', 'company', 'passkey_credentials']);
});
}
};

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_profiles', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('billing_address_line1')->nullable();
$table->string('billing_address_line2')->nullable();
$table->string('billing_city')->nullable();
$table->string('billing_state')->nullable();
$table->string('billing_zip')->nullable();
$table->string('billing_country', 2)->nullable();
$table->string('shipping_address_line1')->nullable();
$table->string('shipping_address_line2')->nullable();
$table->string('shipping_city')->nullable();
$table->string('shipping_state')->nullable();
$table->string('shipping_zip')->nullable();
$table->string('shipping_country', 2)->nullable();
$table->string('tax_id')->nullable();
$table->boolean('tax_exempt')->default(false);
$table->string('company_name')->nullable();
$table->string('company_vat')->nullable();
$table->text('notes')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('user_profiles');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plans', function (Blueprint $table): void {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->text('description')->nullable();
$table->string('service_type');
$table->decimal('price', 10, 2);
$table->string('currency', 3)->default('USD');
$table->string('billing_cycle');
$table->string('stripe_price_id')->nullable()->index();
$table->string('paypal_plan_id')->nullable()->index();
$table->json('features')->nullable();
$table->integer('stock_quantity')->nullable();
$table->string('status')->default('active');
$table->integer('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->foreignId('plan_id')->nullable()->constrained()->after('type');
$table->string('gateway')->default('stripe')->after('plan_id');
$table->string('gateway_subscription_id')->nullable()->after('gateway');
$table->string('gateway_customer_id')->nullable()->after('gateway_subscription_id');
$table->string('gateway_price_id')->nullable()->after('gateway_customer_id');
$table->timestamp('current_period_start')->nullable()->after('gateway_price_id');
$table->timestamp('current_period_end')->nullable()->after('current_period_start');
$table->timestamp('cancelled_at')->nullable()->after('current_period_end');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropForeign(['plan_id']);
$table->dropColumn([
'plan_id',
'gateway',
'gateway_subscription_id',
'gateway_customer_id',
'gateway_price_id',
'current_period_start',
'current_period_end',
'cancelled_at',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoices', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete();
$table->string('gateway');
$table->string('gateway_invoice_id')->nullable()->index();
$table->string('number')->unique();
$table->decimal('total', 10, 2);
$table->decimal('tax', 10, 2)->default(0);
$table->string('currency', 3)->default('USD');
$table->string('status')->default('draft');
$table->string('invoice_pdf')->nullable();
$table->timestamp('due_date')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('invoices');
}
};

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('invoice_items', function (Blueprint $table): void {
$table->id();
$table->foreignId('invoice_id')->constrained()->cascadeOnDelete();
$table->string('description');
$table->decimal('amount', 10, 2);
$table->integer('quantity')->default(1);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('invoice_items');
}
};

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_transactions', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('invoice_id')->nullable()->constrained()->nullOnDelete();
$table->string('gateway');
$table->string('gateway_transaction_id')->nullable()->index();
$table->decimal('amount', 10, 2);
$table->string('currency', 3)->default('USD');
$table->string('status');
$table->string('payment_method')->nullable();
$table->string('description')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('payment_transactions');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('coupons', function (Blueprint $table): void {
$table->id();
$table->string('code')->unique();
$table->string('type');
$table->decimal('value', 10, 2);
$table->string('currency', 3)->nullable();
$table->json('applies_to')->nullable();
$table->integer('max_uses')->nullable();
$table->integer('times_used')->default(0);
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('coupons');
}
};

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('coupon_redemptions', function (Blueprint $table): void {
$table->id();
$table->foreignId('coupon_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('discount_amount', 10, 2);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('coupon_redemptions');
}
};

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('services', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->foreignId('subscription_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('plan_id')->constrained();
$table->string('service_type');
$table->string('platform');
$table->string('platform_service_id')->nullable()->index();
$table->string('status')->default('pending');
$table->string('ipv4_address')->nullable();
$table->string('ipv6_address')->nullable();
$table->string('hostname')->nullable();
$table->string('domain')->nullable();
$table->text('credentials')->nullable();
$table->timestamp('provisioned_at')->nullable();
$table->timestamp('suspended_at')->nullable();
$table->timestamp('terminated_at')->nullable();
$table->boolean('auto_renew')->default(true);
$table->timestamps();
$table->index('user_id');
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('services');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('provisioning_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('action');
$table->string('platform');
$table->json('platform_response')->nullable();
$table->string('status')->default('pending');
$table->text('error_message')->nullable();
$table->foreignId('admin_id')->nullable()->constrained('users')->nullOnDelete();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('provisioning_logs');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('bandwidth_usage', function (Blueprint $table): void {
$table->id();
$table->foreignId('service_id')->constrained()->cascadeOnDelete();
$table->timestamp('period_start');
$table->timestamp('period_end');
$table->unsignedBigInteger('bytes_in')->default(0);
$table->unsignedBigInteger('bytes_out')->default(0);
$table->unsignedBigInteger('total_bytes')->default(0);
$table->unsignedBigInteger('quota_bytes')->default(0);
$table->unsignedBigInteger('overage_bytes')->default(0);
$table->decimal('overage_charge', 10, 2)->default(0);
$table->string('source')->default('elastiflow');
$table->timestamps();
$table->index(['service_id', 'period_start']);
});
}
public function down(): void
{
Schema::dropIfExists('bandwidth_usage');
}
};

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('audit_logs', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
$table->foreignId('admin_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('action');
$table->string('resource_type')->nullable();
$table->unsignedBigInteger('resource_id')->nullable();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->json('changes')->nullable();
$table->timestamps();
$table->index('user_id');
$table->index('admin_id');
$table->index(['resource_type', 'resource_id']);
});
}
public function down(): void
{
Schema::dropIfExists('audit_logs');
}
};

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('support_tickets', function (Blueprint $table): void {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->unsignedBigInteger('supportpal_ticket_id')->nullable()->unique();
$table->string('subject');
$table->string('status')->default('open');
$table->string('priority')->default('medium');
$table->timestamp('last_reply_at')->nullable();
$table->timestamps();
$table->index('user_id');
});
}
public function down(): void
{
Schema::dropIfExists('support_tickets');
}
};

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('announcements', function (Blueprint $table): void {
$table->id();
$table->string('title');
$table->text('content');
$table->string('type')->default('feature');
$table->timestamp('published_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('announcements');
}
};

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class AdminUserSeeder extends Seeder
{
public function run(): void
{
$admin = User::firstOrCreate(
['email' => 'admin@ezscale.cloud'],
[
'name' => 'Admin User',
'password' => Hash::make('admin123!'),
'status' => 'active',
'email_verified_at' => now(),
]
);
if (! $admin->hasRole('admin')) {
$admin->assignRole('admin');
}
if (! $admin->profile) {
$admin->profile()->create([]);
}
$customer = User::firstOrCreate(
['email' => 'user@ezscale.dev'],
[
'name' => 'Test Customer',
'password' => Hash::make('user123!'),
'status' => 'active',
'email_verified_at' => now(),
]
);
if (! $customer->hasRole('customer')) {
$customer->assignRole('customer');
}
if (! $customer->profile) {
$customer->profile()->create([]);
}
}
}

View File

@@ -1,25 +1,19 @@
<?php <?php
declare(strict_types=1);
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User;
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
{ {
use WithoutModelEvents;
/**
* Seed the application's database.
*/
public function run(): void public function run(): void
{ {
// User::factory(10)->create(); $this->call([
RoleAndPermissionSeeder::class,
User::factory()->create([ PlanSeeder::class,
'name' => 'Test User', AdminUserSeeder::class,
'email' => 'test@example.com',
]); ]);
} }
} }

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use App\Models\Plan;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
public function run(): void
{
$plans = [
// VPS Plans
[
'name' => 'VPS Starter',
'slug' => 'vps-starter',
'description' => 'Perfect for small projects and development.',
'service_type' => 'vps',
'price' => 5.99,
'billing_cycle' => 'monthly',
'features' => ['cpu' => '1 vCPU', 'ram' => '1GB', 'disk' => '25GB SSD', 'bandwidth' => '1TB'],
'sort_order' => 1,
],
[
'name' => 'VPS Pro',
'slug' => 'vps-pro',
'description' => 'Ideal for growing applications and websites.',
'service_type' => 'vps',
'price' => 19.99,
'billing_cycle' => 'monthly',
'features' => ['cpu' => '2 vCPU', 'ram' => '4GB', 'disk' => '80GB SSD', 'bandwidth' => '3TB'],
'sort_order' => 2,
],
[
'name' => 'VPS Enterprise',
'slug' => 'vps-enterprise',
'description' => 'High-performance VPS for demanding workloads.',
'service_type' => 'vps',
'price' => 49.99,
'billing_cycle' => 'monthly',
'features' => ['cpu' => '4 vCPU', 'ram' => '16GB', 'disk' => '200GB SSD', 'bandwidth' => '10TB'],
'sort_order' => 3,
],
// Dedicated Server Plans
[
'name' => 'Dedicated Starter',
'slug' => 'dedicated-starter',
'description' => 'Entry-level dedicated server.',
'service_type' => 'dedicated',
'price' => 99.99,
'billing_cycle' => 'monthly',
'features' => ['cpu' => 'Intel Xeon E-2236', 'ram' => '32GB DDR4', 'disk' => '2x 500GB SSD', 'bandwidth' => '10TB'],
'stock_quantity' => 5,
'sort_order' => 10,
],
// Web Hosting Plans
[
'name' => 'Hosting Basic',
'slug' => 'hosting-basic',
'description' => 'Shared hosting for small websites.',
'service_type' => 'hosting',
'price' => 3.99,
'billing_cycle' => 'monthly',
'features' => ['disk' => '10GB SSD', 'bandwidth' => '100GB', 'domains' => '1', 'email' => '5 accounts'],
'sort_order' => 20,
],
// Game Server Plans
[
'name' => 'Minecraft Standard',
'slug' => 'minecraft-standard',
'description' => 'Minecraft server for up to 20 players.',
'service_type' => 'game_server',
'price' => 9.99,
'billing_cycle' => 'monthly',
'features' => ['cpu' => '2 vCPU', 'ram' => '4GB', 'disk' => '30GB SSD', 'players' => '20'],
'sort_order' => 30,
],
];
foreach ($plans as $plan) {
Plan::create(array_merge([
'currency' => 'USD',
'status' => 'active',
], $plan));
}
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
class RoleAndPermissionSeeder extends Seeder
{
public function run(): void
{
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
$permissions = [
'manage users',
'manage services',
'manage plans',
'manage invoices',
'manage coupons',
'view audit logs',
'impersonate users',
];
foreach ($permissions as $permission) {
Permission::create(['name' => $permission]);
}
$adminRole = Role::create(['name' => 'admin']);
$adminRole->givePermissionTo(Permission::all());
Role::create(['name' => 'customer']);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,5 +13,10 @@
"laravel-vite-plugin": "^2.0.0", "laravel-vite-plugin": "^2.0.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"vite": "^7.0.7" "vite": "^7.0.7"
},
"dependencies": {
"@inertiajs/vue3": "^2.3.13",
"@vitejs/plugin-vue": "^6.0.4",
"vue": "^3.5.27"
} }
} }

View File

@@ -23,8 +23,8 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_DATABASE" value="ezscale_billing_test"/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/> <env name="SESSION_DRIVER" value="array"/>

View File

@@ -4,8 +4,9 @@
@source '../../storage/framework/views/*.php'; @source '../../storage/framework/views/*.php';
@source '../**/*.blade.php'; @source '../**/*.blade.php';
@source '../**/*.js'; @source '../**/*.js';
@source '../**/*.vue';
@theme { @theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', --font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji'; 'Segoe UI Symbol', 'Noto Color Emoji';
} }

View File

@@ -0,0 +1,28 @@
<script setup>
defineProps({
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: 'primary',
},
disabled: Boolean,
});
</script>
<template>
<button
:type="type"
:disabled="disabled"
:class="[
'px-4 py-2 text-sm font-medium rounded-md disabled:opacity-50',
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700',
]"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,12 @@
<script setup>
defineProps({
title: String,
});
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 v-if="title" class="text-sm font-medium text-gray-500 mb-2">{{ title }}</h3>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const page = usePage();
const flash = computed(() => page.props.flash || {});
</script>
<template>
<div v-if="flash.success" class="mb-4 rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{{ flash.success }}</p>
</div>
<div v-if="flash.error" class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{{ flash.error }}</p>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: String,
active: Boolean,
});
</script>
<template>
<Link
:href="href"
:class="[
'px-3 py-2 rounded-md text-sm font-medium',
active
? 'bg-gray-100 text-gray-900'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100',
]"
>
<slot />
</Link>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
const page = usePage();
const user = page.props.auth?.user;
</script>
<template>
<div class="min-h-screen bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-white">
EZSCALE <span class="text-xs font-normal text-gray-400">Admin</span>
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700"
>
Dashboard
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<span v-if="user" class="text-sm text-gray-300">{{ user.name }}</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-sm text-gray-400 hover:text-white"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
const page = usePage();
const user = page.props.auth?.user;
</script>
<template>
<div class="min-h-screen bg-gray-50">
<nav class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-gray-900">
EZSCALE
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Dashboard
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<span v-if="user" class="text-sm text-gray-600">{{ user.name }}</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-sm text-gray-600 hover:text-gray-900"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">EZSCALE</h1>
<p class="mt-2 text-sm text-gray-600">Cloud Hosting Platform</p>
</div>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-8">
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
defineOptions({ layout: AdminLayout });
defineProps({
totalCustomers: Number,
totalServices: Number,
activeServices: Number,
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Customers</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalCustomers }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Services</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalServices }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Active Services</h3>
<p class="mt-2 text-3xl font-bold text-green-400">{{ activeServices }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
password: '',
});
const submit = () => {
form.post('/user/confirm-password', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Confirm your password</h2>
<p class="text-sm text-gray-600 mb-6">Please confirm your password before continuing.</p>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Confirm
</button>
</form>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
defineProps({
status: String,
});
const form = useForm({
email: '',
});
const submit = () => {
form.post('/forgot-password');
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Reset your password</h2>
<p class="text-sm text-gray-600 mb-6">Enter your email and we'll send you a reset link.</p>
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">{{ status }}</div>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Send reset link
</button>
<p class="text-center text-sm text-gray-600">
<a href="/login" class="text-blue-600 hover:text-blue-500">Back to login</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post('/login', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Sign in to your account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input v-model="form.remember" type="checkbox" class="rounded border-gray-300 text-blue-600" />
<span class="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">Forgot password?</a>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Sign in
</button>
<p class="text-center text-sm text-gray-600">
Don't have an account? <a href="/register" class="text-blue-600 hover:text-blue-500">Sign up</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/register', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Create an account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Create account
</button>
<p class="text-center text-sm text-gray-600">
Already have an account? <a href="/login" class="text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const props = defineProps({
token: String,
email: String,
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Set new password</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">New Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Reset password
</button>
</form>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const useRecovery = ref(false);
const form = useForm({
code: '',
recovery_code: '',
});
const submit = () => {
form.post('/two-factor-challenge', {
onFinish: () => form.reset(),
});
};
const toggleRecovery = () => {
useRecovery.value = !useRecovery.value;
form.reset();
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Two-Factor Authentication</h2>
<p class="text-sm text-gray-600 mb-6">
<template v-if="!useRecovery">
Enter the authentication code from your authenticator app.
</template>
<template v-else>
Enter one of your emergency recovery codes.
</template>
</p>
<form @submit.prevent="submit" class="space-y-4">
<div v-if="!useRecovery">
<label for="code" class="block text-sm font-medium text-gray-700">Code</label>
<input
id="code"
v-model="form.code"
type="text"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.code" class="mt-1 text-sm text-red-600">{{ form.errors.code }}</p>
</div>
<div v-else>
<label for="recovery_code" class="block text-sm font-medium text-gray-700">Recovery Code</label>
<input
id="recovery_code"
v-model="form.recovery_code"
type="text"
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.recovery_code" class="mt-1 text-sm text-red-600">{{ form.errors.recovery_code }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Verify
</button>
<button
type="button"
@click="toggleRecovery"
class="w-full text-center text-sm text-gray-600 hover:text-gray-900"
>
{{ useRecovery ? 'Use authentication code' : 'Use a recovery code' }}
</button>
</form>
</template>

Some files were not shown because too many files have changed in this diff Show More