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:
Claude Dev
2026-03-14 18:53:37 -04:00
parent f194b60d5c
commit 9a410dc3c8
7 changed files with 228 additions and 171 deletions

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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';

View File

@@ -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'),

View File

@@ -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'),

View File

@@ -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']);
});
}
};

View File

@@ -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');