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[] = [ $paymentMethods[] = [
'id' => $method->id, 'id' => $method->id,
'brand' => $method->card->brand ?? 'unknown', 'brand' => $method->card->brand ?? 'unknown',
'last_four' => $method->card->last_four ?? '****', 'last_four' => $method->card->last4 ?? '****',
'exp_month' => $method->card->exp_month, 'exp_month' => $method->card->exp_month,
'exp_year' => $method->card->exp_year, 'exp_year' => $method->card->exp_year,
'is_default' => $method->id === $defaultPaymentMethod, 'is_default' => $method->id === $defaultPaymentMethod,

View File

@@ -10,6 +10,7 @@ use App\Models\PaymentTransaction;
use App\Models\Plan; use App\Models\Plan;
use App\Models\Service; use App\Models\Service;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -19,17 +20,18 @@ class DashboardController extends Controller
{ {
public function index(): Response public function index(): Response
{ {
$stats = Cache::remember('admin.dashboard.stats', 300, function () {
$totalCustomers = User::role('customer')->count(); $totalCustomers = User::role('customer')->count();
// MRR: sum of plan prices for active subscriptions // MRR: sum of plan prices for active subscriptions
$mrr = Subscription::query() $mrr = (float) Subscription::query()
->where('stripe_status', 'active') ->where('stripe_status', 'active')
->whereNotNull('plan_id') ->whereNotNull('plan_id')
->join('plans', 'subscriptions.plan_id', '=', 'plans.id') ->join('plans', 'subscriptions.plan_id', '=', 'plans.id')
->sum('plans.price'); ->sum('plans.price');
// Total revenue: sum of paid invoice totals // Total revenue: sum of paid invoice totals
$totalRevenue = Invoice::query() $totalRevenue = (float) Invoice::query()
->where('status', 'paid') ->where('status', 'paid')
->sum('total'); ->sum('total');
@@ -42,7 +44,7 @@ class DashboardController extends Controller
->where('status', 'pending') ->where('status', 'pending')
->count(); ->count();
$pendingInvoicesAmount = Invoice::query() $pendingInvoicesAmount = (float) Invoice::query()
->where('status', 'pending') ->where('status', 'pending')
->sum('total'); ->sum('total');
@@ -51,7 +53,7 @@ class DashboardController extends Controller
->where('status', 'overdue') ->where('status', 'overdue')
->count(); ->count();
$overdueAmount = Invoice::query() $overdueAmount = (float) Invoice::query()
->where('status', 'overdue') ->where('status', 'overdue')
->sum('total'); ->sum('total');
@@ -114,13 +116,13 @@ class DashboardController extends Controller
->count(); ->count();
// Revenue this month // Revenue this month
$revenueThisMonth = Invoice::query() $revenueThisMonth = (float) Invoice::query()
->where('status', 'paid') ->where('status', 'paid')
->where('paid_at', '>=', now()->startOfMonth()) ->where('paid_at', '>=', now()->startOfMonth())
->sum('total'); ->sum('total');
// ARR (Annual Recurring Revenue) // ARR (Annual Recurring Revenue)
$arr = (float) $mrr * 12; $arr = $mrr * 12;
// Monthly Revenue Trend (last 12 months) // Monthly Revenue Trend (last 12 months)
$revenueByMonth = PaymentTransaction::query() $revenueByMonth = PaymentTransaction::query()
@@ -174,26 +176,29 @@ class DashboardController extends Controller
->take(10) ->take(10)
->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']); ->get(['id', 'user_id', 'number', 'total', 'due_date', 'status']);
return Inertia::render('Admin/Dashboard', [ return compact(
'totalCustomers' => $totalCustomers, 'totalCustomers',
'mrr' => (float) $mrr, 'mrr',
'totalRevenue' => (float) $totalRevenue, 'totalRevenue',
'activeServices' => $activeServices, 'activeServices',
'pendingInvoicesCount' => $pendingInvoicesCount, 'pendingInvoicesCount',
'pendingInvoicesAmount' => (float) $pendingInvoicesAmount, 'pendingInvoicesAmount',
'overdueCount' => $overdueCount, 'overdueCount',
'overdueAmount' => (float) $overdueAmount, 'overdueAmount',
'recentInvoices' => $recentInvoices, 'recentInvoices',
'recentSubscriptions' => $recentSubscriptions, 'recentSubscriptions',
'popularPlans' => $popularPlans, 'popularPlans',
'revenueByServiceType' => $revenueByServiceType, 'revenueByServiceType',
'newCustomersThisMonth' => $newCustomersThisMonth, 'newCustomersThisMonth',
'revenueThisMonth' => (float) $revenueThisMonth, 'revenueThisMonth',
'arr' => $arr, 'arr',
'revenueByMonth' => $revenueByMonth, 'revenueByMonth',
'customerGrowth' => $customerGrowth, 'customerGrowth',
'churnData' => $churnData, 'churnData',
'overdueInvoices' => $overdueInvoices, '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\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Inertia\Inertia; use Inertia\Inertia;
use Inertia\Response; use Inertia\Response;
@@ -75,7 +76,7 @@ class InvoiceController extends Controller
}); });
// Generate unique invoice number // 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'; $status = ($validated['send_immediately'] ?? false) ? 'pending' : 'draft';

View File

@@ -14,6 +14,7 @@ use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Cashier\Subscription; use Laravel\Cashier\Subscription;
class PayPalWebhookController extends Controller class PayPalWebhookController extends Controller
@@ -188,7 +189,7 @@ class PayPalWebhookController extends Controller
'subscription_id' => $subscription->id, 'subscription_id' => $subscription->id,
'gateway' => 'paypal', 'gateway' => 'paypal',
'gateway_invoice_id' => $resource['id'] ?? null, '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), 'total' => (float) ($resource['amount']['total'] ?? 0),
'tax' => 0, 'tax' => 0,
'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'), 'currency' => strtoupper($resource['amount']['currency'] ?? 'USD'),

View File

@@ -11,6 +11,7 @@ use App\Models\Invoice;
use App\Models\PaymentTransaction; use App\Models\PaymentTransaction;
use App\Models\User; use App\Models\User;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Laravel\Cashier\Http\Controllers\WebhookController; use Laravel\Cashier\Http\Controllers\WebhookController;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
@@ -123,7 +124,7 @@ class StripeWebhookController extends WebhookController
'user_id' => $user->id, 'user_id' => $user->id,
'subscription_id' => $subscription?->id, 'subscription_id' => $subscription?->id,
'gateway' => 'stripe', '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, 'total' => ($stripeInvoice['amount_paid'] ?? 0) / 100,
'tax' => ($stripeInvoice['tax'] ?? 0) / 100, 'tax' => ($stripeInvoice['tax'] ?? 0) / 100,
'currency' => strtoupper($stripeInvoice['currency'] ?? 'usd'), '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 App\Http\Controllers\Account\VpsController;
use Illuminate\Support\Facades\Route; 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('/dashboard', [DashboardController::class, 'index'])->name('account.dashboard');
Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile'); Route::get('/profile', [ProfileController::class, 'show'])->name('account.profile');