Fix impersonation routing, invoice numbers, dashboard caching, indexes, and Stripe property name
- Add impersonation stop route on account subdomain so impersonated users can exit (#11) - Replace non-concurrency-safe invoice number generation (count+1, rand) with date+random string - Wrap admin dashboard stats queries in Cache::remember with 5-minute TTL - Add database indexes on invoices.status, orders.status, audit_logs.action, audit_logs.created_at - Fix last_four to last4 matching Stripe's actual card property name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?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('invoices', function (Blueprint $table) {
|
||||
$table->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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user