diff --git a/website/app/Http/Controllers/Account/BillingController.php b/website/app/Http/Controllers/Account/BillingController.php index 29ab799..032f1b4 100644 --- a/website/app/Http/Controllers/Account/BillingController.php +++ b/website/app/Http/Controllers/Account/BillingController.php @@ -91,7 +91,7 @@ class BillingController extends Controller $paymentMethods[] = [ 'id' => $method->id, 'brand' => $method->card->brand ?? 'unknown', - 'last_four' => $method->card->last_four ?? '****', + 'last_four' => $method->card->last4 ?? '****', 'exp_month' => $method->card->exp_month, 'exp_year' => $method->card->exp_year, 'is_default' => $method->id === $defaultPaymentMethod, diff --git a/website/app/Http/Controllers/Admin/DashboardController.php b/website/app/Http/Controllers/Admin/DashboardController.php index 8cc55e7..6392699 100644 --- a/website/app/Http/Controllers/Admin/DashboardController.php +++ b/website/app/Http/Controllers/Admin/DashboardController.php @@ -10,6 +10,7 @@ use App\Models\PaymentTransaction; use App\Models\Plan; use App\Models\Service; use App\Models\User; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; @@ -19,181 +20,185 @@ class DashboardController extends Controller { public function index(): Response { - $totalCustomers = User::role('customer')->count(); + $stats = Cache::remember('admin.dashboard.stats', 300, function () { + $totalCustomers = User::role('customer')->count(); - // MRR: sum of plan prices for active subscriptions - $mrr = Subscription::query() - ->where('stripe_status', 'active') - ->whereNotNull('plan_id') - ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') - ->sum('plans.price'); + // MRR: sum of plan prices for active subscriptions + $mrr = (float) Subscription::query() + ->where('stripe_status', 'active') + ->whereNotNull('plan_id') + ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->sum('plans.price'); - // Total revenue: sum of paid invoice totals - $totalRevenue = Invoice::query() - ->where('status', 'paid') - ->sum('total'); + // Total revenue: sum of paid invoice totals + $totalRevenue = (float) Invoice::query() + ->where('status', 'paid') + ->sum('total'); - $activeServices = Service::query() - ->where('status', 'active') - ->count(); - - // Pending invoices - $pendingInvoicesCount = Invoice::query() - ->where('status', 'pending') - ->count(); - - $pendingInvoicesAmount = Invoice::query() - ->where('status', 'pending') - ->sum('total'); - - // Overdue invoices - $overdueCount = Invoice::query() - ->where('status', 'overdue') - ->count(); - - $overdueAmount = Invoice::query() - ->where('status', 'overdue') - ->sum('total'); - - // Recent invoices with user info - $recentInvoices = Invoice::query() - ->with('user:id,name,email') - ->latest() - ->limit(10) - ->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); - - // Recent subscriptions with user and plan - $recentSubscriptions = Subscription::query() - ->select([ - 'subscriptions.id', - 'subscriptions.user_id', - 'subscriptions.plan_id', - 'subscriptions.type', - 'subscriptions.stripe_status', - 'subscriptions.gateway', - 'subscriptions.created_at', - ]) - ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') - ->addSelect([ - 'plans.name as plan_name', - 'plans.price as plan_price', - 'plans.billing_cycle as plan_billing_cycle', - ]) - ->leftJoin('users', 'subscriptions.user_id', '=', 'users.id') - ->addSelect([ - 'users.name as user_name', - 'users.email as user_email', - ]) - ->orderByDesc('subscriptions.created_at') - ->limit(10) - ->get(); - - // Popular plans: ordered by active subscription count - $popularPlans = Plan::query() - ->withCount(['services as active_services_count' => function ($query): void { - $query->where('status', 'active'); - }]) - ->where('status', 'active') - ->orderByDesc('active_services_count') - ->limit(8) - ->get(['id', 'name', 'service_type', 'price', 'billing_cycle']); - - // Revenue by service type from plans linked through invoices - $revenueByServiceType = Invoice::query() - ->where('invoices.status', 'paid') - ->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id') - ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') - ->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count')) - ->groupBy('plans.service_type') - ->orderByDesc('revenue') - ->get(); - - // New customers this month - $newCustomersThisMonth = User::role('customer') - ->where('created_at', '>=', now()->startOfMonth()) - ->count(); - - // Revenue this month - $revenueThisMonth = Invoice::query() - ->where('status', 'paid') - ->where('paid_at', '>=', now()->startOfMonth()) - ->sum('total'); - - // ARR (Annual Recurring Revenue) - $arr = (float) $mrr * 12; - - // Monthly Revenue Trend (last 12 months) - $revenueByMonth = PaymentTransaction::query() - ->where('status', 'completed') - ->where('created_at', '>=', now()->subMonths(12)) - ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total") - ->groupBy('month') - ->orderBy('month') - ->get() - ->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]); - - // Customer Growth (last 12 months - new signups per month) - $customerGrowth = User::role('customer') - ->where('created_at', '>=', now()->subMonths(12)) - ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count") - ->groupBy('month') - ->orderBy('month') - ->get() - ->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]); - - // Churn Rate (subscriptions cancelled vs total in last 6 months) - $churnData = []; - for ($i = 5; $i >= 0; $i--) { - $monthStart = now()->subMonths($i)->startOfMonth(); - $monthEnd = now()->subMonths($i)->endOfMonth(); - - $totalAtStart = Subscription::query() - ->where('created_at', '<', $monthStart) - ->where(function ($query) use ($monthStart): void { - $query->whereNull('cancelled_at') - ->orWhere('cancelled_at', '>', $monthStart); - }) + $activeServices = Service::query() + ->where('status', 'active') ->count(); - $cancelled = Subscription::query() - ->whereBetween('cancelled_at', [$monthStart, $monthEnd]) + // Pending invoices + $pendingInvoicesCount = Invoice::query() + ->where('status', 'pending') ->count(); - $churnData[] = [ - 'month' => $monthStart->format('Y-m'), - 'rate' => $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0, - 'cancelled' => $cancelled, - ]; - } + $pendingInvoicesAmount = (float) Invoice::query() + ->where('status', 'pending') + ->sum('total'); - // Overdue Invoices (detailed list) - $overdueInvoices = Invoice::query() - ->where('status', 'overdue') - ->with('user:id,name,email') - ->latest('due_date') - ->take(10) - ->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']); + // Overdue invoices + $overdueCount = Invoice::query() + ->where('status', 'overdue') + ->count(); - return Inertia::render('Admin/Dashboard', [ - 'totalCustomers' => $totalCustomers, - 'mrr' => (float) $mrr, - 'totalRevenue' => (float) $totalRevenue, - 'activeServices' => $activeServices, - 'pendingInvoicesCount' => $pendingInvoicesCount, - 'pendingInvoicesAmount' => (float) $pendingInvoicesAmount, - 'overdueCount' => $overdueCount, - 'overdueAmount' => (float) $overdueAmount, - 'recentInvoices' => $recentInvoices, - 'recentSubscriptions' => $recentSubscriptions, - 'popularPlans' => $popularPlans, - 'revenueByServiceType' => $revenueByServiceType, - 'newCustomersThisMonth' => $newCustomersThisMonth, - 'revenueThisMonth' => (float) $revenueThisMonth, - 'arr' => $arr, - 'revenueByMonth' => $revenueByMonth, - 'customerGrowth' => $customerGrowth, - 'churnData' => $churnData, - 'overdueInvoices' => $overdueInvoices, - ]); + $overdueAmount = (float) Invoice::query() + ->where('status', 'overdue') + ->sum('total'); + + // Recent invoices with user info + $recentInvoices = Invoice::query() + ->with('user:id,name,email') + ->latest() + ->limit(10) + ->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); + + // Recent subscriptions with user and plan + $recentSubscriptions = Subscription::query() + ->select([ + 'subscriptions.id', + 'subscriptions.user_id', + 'subscriptions.plan_id', + 'subscriptions.type', + 'subscriptions.stripe_status', + 'subscriptions.gateway', + 'subscriptions.created_at', + ]) + ->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->addSelect([ + 'plans.name as plan_name', + 'plans.price as plan_price', + 'plans.billing_cycle as plan_billing_cycle', + ]) + ->leftJoin('users', 'subscriptions.user_id', '=', 'users.id') + ->addSelect([ + 'users.name as user_name', + 'users.email as user_email', + ]) + ->orderByDesc('subscriptions.created_at') + ->limit(10) + ->get(); + + // Popular plans: ordered by active subscription count + $popularPlans = Plan::query() + ->withCount(['services as active_services_count' => function ($query): void { + $query->where('status', 'active'); + }]) + ->where('status', 'active') + ->orderByDesc('active_services_count') + ->limit(8) + ->get(['id', 'name', 'service_type', 'price', 'billing_cycle']); + + // Revenue by service type from plans linked through invoices + $revenueByServiceType = Invoice::query() + ->where('invoices.status', 'paid') + ->join('subscriptions', 'invoices.subscription_id', '=', 'subscriptions.id') + ->join('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->select('plans.service_type', DB::raw('SUM(invoices.total) as revenue'), DB::raw('COUNT(invoices.id) as invoice_count')) + ->groupBy('plans.service_type') + ->orderByDesc('revenue') + ->get(); + + // New customers this month + $newCustomersThisMonth = User::role('customer') + ->where('created_at', '>=', now()->startOfMonth()) + ->count(); + + // Revenue this month + $revenueThisMonth = (float) Invoice::query() + ->where('status', 'paid') + ->where('paid_at', '>=', now()->startOfMonth()) + ->sum('total'); + + // ARR (Annual Recurring Revenue) + $arr = $mrr * 12; + + // Monthly Revenue Trend (last 12 months) + $revenueByMonth = PaymentTransaction::query() + ->where('status', 'completed') + ->where('created_at', '>=', now()->subMonths(12)) + ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, SUM(amount) as total") + ->groupBy('month') + ->orderBy('month') + ->get() + ->map(fn ($row) => ['month' => $row->month, 'total' => (float) $row->total]); + + // Customer Growth (last 12 months - new signups per month) + $customerGrowth = User::role('customer') + ->where('created_at', '>=', now()->subMonths(12)) + ->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month, COUNT(*) as count") + ->groupBy('month') + ->orderBy('month') + ->get() + ->map(fn ($row) => ['month' => $row->month, 'count' => (int) $row->count]); + + // Churn Rate (subscriptions cancelled vs total in last 6 months) + $churnData = []; + for ($i = 5; $i >= 0; $i--) { + $monthStart = now()->subMonths($i)->startOfMonth(); + $monthEnd = now()->subMonths($i)->endOfMonth(); + + $totalAtStart = Subscription::query() + ->where('created_at', '<', $monthStart) + ->where(function ($query) use ($monthStart): void { + $query->whereNull('cancelled_at') + ->orWhere('cancelled_at', '>', $monthStart); + }) + ->count(); + + $cancelled = Subscription::query() + ->whereBetween('cancelled_at', [$monthStart, $monthEnd]) + ->count(); + + $churnData[] = [ + 'month' => $monthStart->format('Y-m'), + 'rate' => $totalAtStart > 0 ? round(($cancelled / $totalAtStart) * 100, 1) : 0, + 'cancelled' => $cancelled, + ]; + } + + // Overdue Invoices (detailed list) + $overdueInvoices = Invoice::query() + ->where('status', 'overdue') + ->with('user:id,name,email') + ->latest('due_date') + ->take(10) + ->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']); + + return compact( + 'totalCustomers', + 'mrr', + 'totalRevenue', + 'activeServices', + 'pendingInvoicesCount', + 'pendingInvoicesAmount', + 'overdueCount', + 'overdueAmount', + 'recentInvoices', + 'recentSubscriptions', + 'popularPlans', + 'revenueByServiceType', + 'newCustomersThisMonth', + 'revenueThisMonth', + 'arr', + 'revenueByMonth', + 'customerGrowth', + 'churnData', + 'overdueInvoices', + ); + }); + + return Inertia::render('Admin/Dashboard', $stats); } } diff --git a/website/app/Http/Controllers/Admin/InvoiceController.php b/website/app/Http/Controllers/Admin/InvoiceController.php index 5c5e1ef..eca5952 100644 --- a/website/app/Http/Controllers/Admin/InvoiceController.php +++ b/website/app/Http/Controllers/Admin/InvoiceController.php @@ -15,6 +15,7 @@ use Barryvdh\DomPDF\Facade\Pdf; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Inertia\Inertia; use Inertia\Response; @@ -75,7 +76,7 @@ class InvoiceController extends Controller }); // Generate unique invoice number - $number = 'INV-'.str_pad((string) (Invoice::query()->count() + 1), 6, '0', STR_PAD_LEFT); + $number = 'INV-'.now()->format('Ymd').'-'.strtoupper(Str::random(6)); $status = ($validated['send_immediately'] ?? false) ? 'pending' : 'draft'; diff --git a/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php index 5ad32a5..159ef69 100644 --- a/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php +++ b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php @@ -14,6 +14,7 @@ use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Laravel\Cashier\Subscription; class PayPalWebhookController extends Controller @@ -188,7 +189,7 @@ class PayPalWebhookController extends Controller 'subscription_id' => $subscription->id, 'gateway' => 'paypal', 'gateway_invoice_id' => $resource['id'] ?? null, - 'number' => config('billing.invoice.prefix').'-'.now()->format('Ymd').'-'.rand(1000, 9999), + 'number' => config('billing.invoice.prefix', 'INV').'-'.now()->format('Ymd').'-'.strtoupper(Str::random(6)), 'total' => (float) ($resource['amount']['total'] ?? 0), 'tax' => 0, 'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'), diff --git a/website/app/Http/Controllers/Webhooks/StripeWebhookController.php b/website/app/Http/Controllers/Webhooks/StripeWebhookController.php index 995c5c3..654b7c0 100644 --- a/website/app/Http/Controllers/Webhooks/StripeWebhookController.php +++ b/website/app/Http/Controllers/Webhooks/StripeWebhookController.php @@ -11,6 +11,7 @@ use App\Models\Invoice; use App\Models\PaymentTransaction; use App\Models\User; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Str; use Laravel\Cashier\Http\Controllers\WebhookController; use Symfony\Component\HttpFoundation\Response; @@ -123,7 +124,7 @@ class StripeWebhookController extends WebhookController 'user_id' => $user->id, 'subscription_id' => $subscription?->id, 'gateway' => 'stripe', - 'number' => $stripeInvoice['number'] ?? config('billing.invoice.prefix').'-'.now()->format('Ymd').'-'.rand(1000, 9999), + 'number' => $stripeInvoice['number'] ?? config('billing.invoice.prefix', 'INV').'-'.now()->format('Ymd').'-'.strtoupper(Str::random(6)), 'total' => ($stripeInvoice['amount_paid'] ?? 0) / 100, 'tax' => ($stripeInvoice['tax'] ?? 0) / 100, 'currency' => strtoupper($stripeInvoice['currency'] ?? 'usd'), diff --git a/website/database/migrations/2026_03_14_224955_add_performance_indexes.php b/website/database/migrations/2026_03_14_224955_add_performance_indexes.php new file mode 100644 index 0000000..f5c17d1 --- /dev/null +++ b/website/database/migrations/2026_03_14_224955_add_performance_indexes.php @@ -0,0 +1,46 @@ +index('status'); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->index('status'); + }); + + Schema::table('audit_logs', function (Blueprint $table) { + $table->index('action'); + $table->index('created_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('invoices', function (Blueprint $table) { + $table->dropIndex(['status']); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->dropIndex(['status']); + }); + + Schema::table('audit_logs', function (Blueprint $table) { + $table->dropIndex(['action']); + $table->dropIndex(['created_at']); + }); + } +}; diff --git a/website/routes/account.php b/website/routes/account.php index cf17157..9bc186d 100644 --- a/website/routes/account.php +++ b/website/routes/account.php @@ -15,6 +15,9 @@ use App\Http\Controllers\Account\UpgradeController; use App\Http\Controllers\Account\VpsController; use Illuminate\Support\Facades\Route; +// Impersonation stop (must be on account subdomain since impersonated user lacks admin role) +Route::post('/impersonate/stop', [\App\Http\Controllers\Admin\ImpersonationController::class, 'stop'])->name('impersonation.stop'); + Route::get('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard'); Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');