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,17 +20,18 @@ class DashboardController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$stats = Cache::remember('admin.dashboard.stats', 300, function () {
|
||||
$totalCustomers = User::role('customer')->count();
|
||||
|
||||
// MRR: sum of plan prices for active subscriptions
|
||||
$mrr = Subscription::query()
|
||||
$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()
|
||||
$totalRevenue = (float) Invoice::query()
|
||||
->where('status', 'paid')
|
||||
->sum('total');
|
||||
|
||||
@@ -42,7 +44,7 @@ class DashboardController extends Controller
|
||||
->where('status', 'pending')
|
||||
->count();
|
||||
|
||||
$pendingInvoicesAmount = Invoice::query()
|
||||
$pendingInvoicesAmount = (float) Invoice::query()
|
||||
->where('status', 'pending')
|
||||
->sum('total');
|
||||
|
||||
@@ -51,7 +53,7 @@ class DashboardController extends Controller
|
||||
->where('status', 'overdue')
|
||||
->count();
|
||||
|
||||
$overdueAmount = Invoice::query()
|
||||
$overdueAmount = (float) Invoice::query()
|
||||
->where('status', 'overdue')
|
||||
->sum('total');
|
||||
|
||||
@@ -114,13 +116,13 @@ class DashboardController extends Controller
|
||||
->count();
|
||||
|
||||
// Revenue this month
|
||||
$revenueThisMonth = Invoice::query()
|
||||
$revenueThisMonth = (float) Invoice::query()
|
||||
->where('status', 'paid')
|
||||
->where('paid_at', '>=', now()->startOfMonth())
|
||||
->sum('total');
|
||||
|
||||
// ARR (Annual Recurring Revenue)
|
||||
$arr = (float) $mrr * 12;
|
||||
$arr = $mrr * 12;
|
||||
|
||||
// Monthly Revenue Trend (last 12 months)
|
||||
$revenueByMonth = PaymentTransaction::query()
|
||||
@@ -174,26 +176,29 @@ class DashboardController extends Controller
|
||||
->take(10)
|
||||
->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']);
|
||||
|
||||
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,
|
||||
]);
|
||||
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