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,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
declare(strict_types=1);
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Auth\MustVerifyEmail;
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\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, Notifiable;
use Billable, HasApiTokens, HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
/**
* The attributes that are mass assignable.
*
* @var list<string>
*/
/** @var list<string> */
protected $fillable = [
'name',
'email',
'password',
'status',
'phone',
'company',
];
/**
* The attributes that should be hidden for serialization.
*
* @var list<string>
*/
/** @var list<string> */
protected $hidden = [
'password',
'remember_token',
'two_factor_secret',
'two_factor_recovery_codes',
];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
/** @return array<string, string> */
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'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);
}
}