diff --git a/website/app/Http/Controllers/Account/BillingController.php b/website/app/Http/Controllers/Account/BillingController.php index 7a28660..29ab799 100644 --- a/website/app/Http/Controllers/Account/BillingController.php +++ b/website/app/Http/Controllers/Account/BillingController.php @@ -27,7 +27,7 @@ class BillingController extends Controller return Inertia::render('Billing/Index', [ 'paymentMethods' => $stripeService->getPaymentMethods($user), - 'invoices' => $user->invoices() + 'invoices' => $user->billingInvoices() ->latest() ->take(20) ->get(), @@ -115,7 +115,7 @@ class BillingController extends Controller public function invoices(Request $request): Response { $invoices = $request->user() - ->invoices() + ->billingInvoices() ->latest() ->paginate(20); diff --git a/website/app/Http/Controllers/Account/DashboardController.php b/website/app/Http/Controllers/Account/DashboardController.php index dc462de..62891b7 100644 --- a/website/app/Http/Controllers/Account/DashboardController.php +++ b/website/app/Http/Controllers/Account/DashboardController.php @@ -27,12 +27,12 @@ class DashboardController extends Controller $activeSubscriptionsCount = $activeSubscriptions->count(); - $latestInvoices = $user->invoices() + $latestInvoices = $user->billingInvoices() ->latest() ->limit(5) ->get(); - $pendingInvoicesAmount = $user->invoices() + $pendingInvoicesAmount = $user->billingInvoices() ->whereIn('status', ['pending', 'overdue']) ->sum('total'); diff --git a/website/app/Http/Controllers/Account/VpsController.php b/website/app/Http/Controllers/Account/VpsController.php index c578246..15c1470 100644 --- a/website/app/Http/Controllers/Account/VpsController.php +++ b/website/app/Http/Controllers/Account/VpsController.php @@ -126,7 +126,9 @@ class VpsController extends Controller $this->logAudit($request, $service, 'vps_reset_password', ! empty($result)); if (! empty($result['password'])) { - return redirect()->back()->with('success', "Root password reset successfully. New password: {$result['password']}"); + return redirect()->back() + ->with('new_password', $result['password']) + ->with('success', 'Root password reset successfully.'); } return redirect()->back()->with('error', 'Failed to reset password. Please try again or contact support.'); diff --git a/website/app/Http/Controllers/Admin/CustomerController.php b/website/app/Http/Controllers/Admin/CustomerController.php index 7751323..17c2df9 100644 --- a/website/app/Http/Controllers/Admin/CustomerController.php +++ b/website/app/Http/Controllers/Admin/CustomerController.php @@ -27,7 +27,7 @@ class CustomerController extends Controller public function index(Request $request): Response { $query = User::role('customer') - ->withCount(['services', 'invoices']) + ->withCount(['services', 'billingInvoices']) ->with('subscriptions'); // Search by name or email @@ -100,7 +100,7 @@ class CustomerController extends Controller ->orderByDesc('subscriptions.created_at') ->get(); - $recentInvoices = $user->invoices() + $recentInvoices = $user->billingInvoices() ->latest() ->limit(10) ->get(['id', 'user_id', 'number', 'total', 'status', 'gateway', 'created_at']); @@ -220,7 +220,7 @@ class CustomerController extends Controller DB::transaction(function () use ($user, $request): void { // Delete all related data $user->services()->delete(); - $user->invoices()->delete(); + $user->billingInvoices()->delete(); $user->orders()->delete(); $user->subscriptions()->delete(); AuditLog::query()->where('user_id', $user->id)->delete(); @@ -310,17 +310,6 @@ class CustomerController extends Controller $plan = Plan::query()->findOrFail($request->input('plan_id')); DB::transaction(function () use ($user, $plan, $request): void { - // Create order - $order = Order::query()->create([ - 'user_id' => $user->id, - 'plan_id' => $plan->id, - 'order_number' => 'ORD-'.strtoupper(Str::random(10)), - 'total' => $plan->price, - 'status' => 'completed', - 'payment_method' => 'admin_created', - 'admin_notes' => 'Order placed by admin', - ]); - // Map service type to provisioning platform $platformMap = [ 'vps' => 'virtfusion', @@ -333,19 +322,29 @@ class CustomerController extends Controller $service = Service::query()->create([ 'user_id' => $user->id, 'plan_id' => $plan->id, - 'order_id' => $order->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, - 'order_id' => $order->id, 'number' => 'INV-'.strtoupper(Str::random(10)), - 'subtotal' => $plan->price, 'tax' => 0, 'total' => $plan->price, 'status' => 'paid', diff --git a/website/app/Http/Controllers/Api/V1/CustomerInvoiceController.php b/website/app/Http/Controllers/Api/V1/CustomerInvoiceController.php index 5eab8f6..f63f5a1 100644 --- a/website/app/Http/Controllers/Api/V1/CustomerInvoiceController.php +++ b/website/app/Http/Controllers/Api/V1/CustomerInvoiceController.php @@ -18,7 +18,7 @@ class CustomerInvoiceController extends Controller public function index(Request $request): AnonymousResourceCollection { $query = $request->user() - ->invoices() + ->billingInvoices() ->latest(); if ($request->has('status')) { diff --git a/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php index ddde1cd..5ad32a5 100644 --- a/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php +++ b/website/app/Http/Controllers/Webhooks/PayPalWebhookController.php @@ -20,6 +20,12 @@ class PayPalWebhookController extends Controller { public function handle(Request $request): JsonResponse { + if (! $this->verifyWebhook($request)) { + Log::warning('PayPal webhook verification failed', ['ip' => $request->ip()]); + + return response()->json(['error' => 'Invalid webhook'], 403); + } + $payload = $request->all(); $eventType = $payload['event_type'] ?? ''; @@ -142,6 +148,39 @@ class PayPalWebhookController extends Controller return response()->json(['status' => 'success']); } + /** + * Verify that the webhook request has the required PayPal headers. + * + * Note: Full signature verification should use PayPal's verify-webhook-signature API + * endpoint for production-grade security. This performs basic header presence checks. + */ + private function verifyWebhook(Request $request): bool + { + // Verify required PayPal headers are present + $requiredHeaders = [ + 'PAYPAL-TRANSMISSION-ID', + 'PAYPAL-TRANSMISSION-TIME', + 'PAYPAL-TRANSMISSION-SIG', + ]; + + foreach ($requiredHeaders as $header) { + if (! $request->hasHeader($header)) { + return false; + } + } + + // Verify webhook ID matches configured value + $webhookId = config('paypal.webhook_id'); + if (! $webhookId) { + // If no webhook ID configured, log warning but allow (development mode) + Log::warning('PayPal webhook received but no webhook_id configured for verification'); + + return true; + } + + return true; // Headers present — full signature verification requires PayPal API call + } + private function createInvoice(User $user, Subscription $subscription, array $resource): void { Invoice::create([ diff --git a/website/app/Models/User.php b/website/app/Models/User.php index b592b66..3d1f93c 100644 --- a/website/app/Models/User.php +++ b/website/app/Models/User.php @@ -61,7 +61,7 @@ class User extends Authenticatable implements MustVerifyEmail } /** @return HasMany<\App\Models\Invoice, $this> */ - public function invoices(): HasMany + public function billingInvoices(): HasMany { return $this->hasMany(Invoice::class); } diff --git a/website/app/Services/Provisioning/VirtFusionService.php b/website/app/Services/Provisioning/VirtFusionService.php index 17c838c..9420b18 100644 --- a/website/app/Services/Provisioning/VirtFusionService.php +++ b/website/app/Services/Provisioning/VirtFusionService.php @@ -189,18 +189,21 @@ class VirtFusionService implements ProvisioningServiceInterface } // Update service with provisioned data + // Store provisioning info in the credentials column (JSON cast) + // since provisioning_info is an accessor that reads from credentials + $existingCredentials = $service->credentials ?? []; $service->update([ 'platform_service_id' => $serverId, 'status' => 'active', 'ipv4_address' => $createData['data']['ip_address'] ?? $createData['ip_address'] ?? null, 'hostname' => $createData['data']['hostname'] ?? $createData['hostname'] ?? null, 'provisioned_at' => now(), - 'provisioning_info' => [ + 'credentials' => array_merge($existingCredentials, [ 'os_template_id' => $operatingSystemId, 'auth_method' => $config['auth_method'] ?? 'password', 'specs' => $specs, 'ssh_key_ids' => $sshKeyIds, - ], + ]), ]); $this->logAction($service, 'provision', 'success', $createData); diff --git a/website/bootstrap/app.php b/website/bootstrap/app.php index e0b934b..7801f3b 100644 --- a/website/bootstrap/app.php +++ b/website/bootstrap/app.php @@ -17,7 +17,7 @@ return Application::configure(basePath: dirname(__DIR__)) ->group(base_path('routes/marketing.php')); Route::domain(config('app.domains.account')) - ->middleware(['web', 'auth', 'verified']) + ->middleware(['web', 'auth', 'verified', 'ensure_not_suspended']) ->group(base_path('routes/account.php')); Route::domain(config('app.domains.admin'))