Files
website/website/app/Http/Controllers/Admin/CustomerController.php
Claude Dev f194b60d5c Fix 5 critical issues: provisioning storage, webhook auth, password exposure, middleware, model conflict
- Store VirtFusion provisioning info in credentials column instead of non-existent provisioning_info key
- Add PayPal webhook header verification to reject unsigned requests
- Stop exposing VPS root password in flash message, use separate new_password key
- Apply ensure_not_suspended middleware to account routes
- Rename User::invoices() to billingInvoices() to avoid overriding Cashier's invoices() method

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 18:47:31 -04:00

374 lines
12 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateCustomerRequest;
use App\Models\AuditLog;
use App\Models\Invoice;
use App\Models\Order;
use App\Models\Plan;
use App\Models\Service;
use App\Models\User;
use App\Notifications\AdminNotification;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Inertia\Inertia;
use Inertia\Response;
class CustomerController extends Controller
{
public function index(Request $request): Response
{
$query = User::role('customer')
->withCount(['services', 'billingInvoices'])
->with('subscriptions');
// Search by name or email
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search): void {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
}
// Filter by status
if ($status = $request->input('status')) {
$query->where('status', $status);
}
// Sort
$sortBy = $request->input('sort', 'created_at');
$sortDir = $request->input('direction', 'desc');
$allowedSorts = ['name', 'email', 'created_at', 'status'];
if (in_array($sortBy, $allowedSorts, true)) {
$query->orderBy($sortBy, $sortDir === 'asc' ? 'asc' : 'desc');
}
$customers = $query->paginate(15)->withQueryString();
// Add subscriptions_count manually since it's a Cashier relationship
$customers->getCollection()->transform(function (User $user): User {
$user->setAttribute('subscriptions_count', $user->subscriptions->count());
unset($user->subscriptions);
return $user;
});
return Inertia::render('Admin/Customers/Index', [
'customers' => $customers,
'filters' => [
'search' => $request->input('search', ''),
'status' => $request->input('status', ''),
'sort' => $sortBy,
'direction' => $sortDir,
],
]);
}
public function show(User $user): Response
{
$user->load(['profile', 'services.plan']);
// Load subscriptions with plan info via join
$subscriptions = $user->subscriptions()
->select([
'subscriptions.id',
'subscriptions.user_id',
'subscriptions.plan_id',
'subscriptions.type',
'subscriptions.stripe_status',
'subscriptions.gateway',
'subscriptions.current_period_start',
'subscriptions.current_period_end',
'subscriptions.ends_at',
'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',
])
->orderByDesc('subscriptions.created_at')
->get();
$recentInvoices = $user->billingInvoices()
->latest()
->limit(10)
->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']);
$auditLogs = AuditLog::query()
->where(function ($q) use ($user): void {
$q->where('user_id', $user->id)
->orWhere(function ($q2) use ($user): void {
$q2->where('resource_type', 'user')
->where('resource_id', $user->id);
});
})
->with(['user:id,name,email', 'admin:id,name,email'])
->latest()
->paginate(15, ['*'], 'audit_page');
$plans = Plan::query()
->where('status', 'active')
->orderBy('service_type')
->orderBy('price')
->get(['id', 'name', 'price', 'billing_cycle', 'service_type']);
return Inertia::render('Admin/Customers/Show', [
'customer' => $user,
'subscriptions' => $subscriptions,
'recentInvoices' => $recentInvoices,
'auditLogs' => $auditLogs,
'plans' => $plans,
]);
}
public function edit(User $user): Response
{
return Inertia::render('Admin/Customers/Edit', [
'customer' => $user,
]);
}
public function update(UpdateCustomerRequest $request, User $user): RedirectResponse
{
$oldStatus = $user->status;
$user->update($request->validated());
// Log status change if it occurred
if ($oldStatus !== $user->status) {
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'customer_status_changed',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => ['old_status' => $oldStatus, 'new_status' => $user->status],
]);
}
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'customer_updated',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => $request->validated(),
]);
return redirect()->route('customers.show', $user)
->with('success', 'Customer updated successfully.');
}
public function suspend(User $user): RedirectResponse
{
$user->update(['status' => 'suspended']);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => auth()->id(),
'action' => 'suspend_account',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Customer {$user->name} has been suspended.");
}
public function unsuspend(User $user): RedirectResponse
{
$user->update(['status' => 'active']);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => auth()->id(),
'action' => 'unsuspend_account',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => request()->ip(),
'user_agent' => request()->userAgent(),
]);
return redirect()->back()->with('success', "Customer {$user->name} has been unsuspended.");
}
public function purge(Request $request, User $user): RedirectResponse
{
if ($user->isAdmin()) {
return redirect()->back()->with('error', 'Cannot purge admin users.');
}
$userName = $user->name;
$userEmail = $user->email;
DB::transaction(function () use ($user, $request): void {
// Delete all related data
$user->services()->delete();
$user->billingInvoices()->delete();
$user->orders()->delete();
$user->subscriptions()->delete();
AuditLog::query()->where('user_id', $user->id)->delete();
AuditLog::create([
'admin_id' => $request->user()->id,
'action' => 'customer_purged',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'email' => $user->email,
'name' => $user->name,
],
]);
// Delete the user
$user->delete();
});
return redirect()->route('customers.index')
->with('success', "Customer {$userName} ({$userEmail}) has been permanently deleted.");
}
public function resetPassword(Request $request, User $user): RedirectResponse
{
$newPassword = Str::random(16);
$user->update([
'password' => Hash::make($newPassword),
]);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'password_reset',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
]);
// Send email with new password
$user->notify(new \App\Notifications\AdminPasswordResetNotification($newPassword));
return redirect()->back()
->with('success', "Password reset email sent to {$user->email}.");
}
public function sendNotification(Request $request, User $user): RedirectResponse
{
$request->validate([
'subject' => 'required|string|max:255',
'message' => 'required|string',
]);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'notification_sent',
'resource_type' => 'user',
'resource_id' => $user->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'subject' => $request->input('subject'),
],
]);
$user->notify(new AdminNotification(
$request->input('subject'),
$request->input('message')
));
return redirect()->back()
->with('success', "Notification sent to {$user->email}.");
}
public function placeOrder(Request $request, User $user): RedirectResponse
{
$request->validate([
'plan_id' => 'required|exists:plans,id',
'billing_cycle' => 'required|in:monthly,quarterly,semi_annually,annually',
]);
$plan = Plan::query()->findOrFail($request->input('plan_id'));
DB::transaction(function () use ($user, $plan, $request): void {
// Map service type to provisioning platform
$platformMap = [
'vps' => 'virtfusion',
'dedicated' => 'synergycp',
'hosting' => 'enhance',
'game' => 'pterodactyl',
];
// Create service
$service = Service::query()->create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'service_type' => $plan->service_type,
'platform' => $platformMap[$plan->service_type] ?? $plan->service_type,
'status' => 'active',
'credentials' => [],
]);
// Create order (linked to service)
$order = Order::query()->create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'service_id' => $service->id,
'order_number' => 'ORD-'.strtoupper(Str::random(10)),
'total' => $plan->price,
'status' => 'completed',
'payment_gateway' => 'manual',
'admin_notes' => 'Order placed by admin',
'completed_at' => now(),
]);
// Create invoice
Invoice::query()->create([
'user_id' => $user->id,
'number' => 'INV-'.strtoupper(Str::random(10)),
'tax' => 0,
'total' => $plan->price,
'status' => 'paid',
'gateway' => 'manual',
'paid_at' => now(),
]);
AuditLog::create([
'user_id' => $user->id,
'admin_id' => $request->user()->id,
'action' => 'order_placed',
'resource_type' => 'order',
'resource_id' => $order->id,
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'changes' => [
'plan_id' => $plan->id,
'plan_name' => $plan->name,
],
]);
});
return redirect()->back()
->with('success', "Order for {$plan->name} created successfully.");
}
}