- 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>
374 lines
12 KiB
PHP
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.");
|
|
}
|
|
}
|