validate([ 'webhook_id' => 'required|string', 'webhook_event' => 'required|array', ]); $webhookId = $request->webhook_id; $event = $request->webhook_event; $environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(config('services.paypal.client_id'), config('services.paypal.secret')) : new LiveEnvironment(config('services.paypal.client_id'), config('services.paypal.secret')); $client = PayPalClient::client($environment); $signatureVerification = new VerifyWebhookSignature(); $signatureVerification->setClient($client); $signatureVerification->setWebhookId($webhookId); $signatureVerification->setAuthAlgo($request->header('PayPal-Auth-Algo')); $signatureVerification->setTransmissionId($request->header('PayPal-Transmission-Id')); $signatureVerification->setTransmissionSig($request->header('PayPal-Transmission-Sig')); $signatureVerification->setTransmissionTime($request->header('PayPal-Transmission-Time')); $signatureVerification->setWebhookBody($request->getContent()); $signatureVerification->setWebhookCertUrl($request->header('PayPal-Cert-Url')); try { $verificationResult = $signatureVerification->verify(); if ($verificationResult->getVerificationStatus() === 'SUCCESS') { // Process the webhook event $this->handleEvent($event); return response()->json(['status' => 'SUCCESS'], 200); } else { Log::warning('PayPal webhook verification failed', ['status' => $verificationResult->getVerificationStatus()]); return response()->json(['status' => 'FAILURE'], 400); } } catch (\Exception $e) { Log::error('PayPal webhook verification error: ' . $e->getMessage()); return response()->json(['status' => 'FAILURE'], 500); } } private function handleEvent(array $event): void { $eventType = $event['event_type'] ?? ''; $resource = $event['resource'] ?? []; Log::info('PayPal webhook received', ['event_type' => $eventType, 'resource_id' => $resource['id'] ?? 'unknown']); switch ($eventType) { case 'CHECKOUT.ORDER.APPROVED': // Handle order approval if needed break; case 'PAYMENT.CAPTURE.COMPLETED': $this->handleCaptureCompleted($resource); break; case 'PAYMENT.CAPTURE.DENIED': $this->handleCaptureDenied($resource); break; case 'BILLING.SUBSCRIPTION.ACTIVATED': // Handle subscription activation for SaaS $this->handleSubscriptionActivated($resource); break; case 'BILLING.SUBSCRIPTION.CANCELLED': $this->handleSubscriptionCancelled($resource); break; default: Log::info('Unhandled PayPal webhook event', ['event_type' => $eventType]); } } private function handleCaptureCompleted(array $capture): void { $orderId = $capture['id'] ?? null; if (!$orderId) { return; } // Idempotent check $purchase = PackagePurchase::where('provider_id', $orderId)->first(); if ($purchase) { Log::info('PayPal capture already processed', ['order_id' => $orderId]); return; } // Extract metadata from custom_id if available, but for webhook, use order ID // Fetch order to get custom_id $this->processPurchaseFromOrder($orderId, 'completed'); } private function handleCaptureDenied(array $capture): void { $orderId = $capture['id'] ?? null; Log::warning('PayPal capture denied', ['order_id' => $orderId]); // Handle denial, e.g., notify tenant or refund logic if needed // For now, log } private function handleSubscriptionActivated(array $subscription): void { $subscriptionId = $subscription['id'] ?? null; if (!$subscriptionId) { return; } // Update tenant subscription status // Assume metadata has tenant_id $customId = $subscription['custom_id'] ?? null; if ($customId) { $metadata = json_decode($customId, true); $tenantId = $metadata['tenant_id'] ?? null; if ($tenantId) { $tenant = Tenant::find($tenantId); if ($tenant) { $tenant->update(['subscription_status' => 'active']); Log::info('PayPal subscription activated', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]); } } } } private function handleSubscriptionCancelled(array $subscription): void { $subscriptionId = $subscription['id'] ?? null; if (!$subscriptionId) { return; } // Update tenant to cancelled $customId = $subscription['custom_id'] ?? null; if ($customId) { $metadata = json_decode($customId, true); $tenantId = $metadata['tenant_id'] ?? null; if ($tenantId) { $tenant = Tenant::find($tenantId); if ($tenant) { $tenant->update(['subscription_status' => 'cancelled']); // Deactivate TenantPackage TenantPackage::where('tenant_id', $tenantId)->update(['active' => false]); Log::info('PayPal subscription cancelled', ['subscription_id' => $subscriptionId, 'tenant_id' => $tenantId]); } } } } private function processPurchaseFromOrder(string $orderId, string $status): void { // Fetch order details $environment = config('services.paypal.sandbox', true) ? new SandboxEnvironment(config('services.paypal.client_id'), config('services.paypal.secret')) : new LiveEnvironment(config('services.paypal.client_id'), config('services.paypal.secret')); $client = PayPalClient::client($environment); $showOrder = new OrdersGetRequest($orderId); $showOrder->prefer('return=representation'); try { $response = $client->execute($showOrder); $order = $response->result; $customId = $order->purchaseUnits[0]->customId ?? null; if (!$customId) { Log::error('No custom_id in PayPal order', ['order_id' => $orderId]); return; } $metadata = json_decode($customId, true); $tenantId = $metadata['tenant_id'] ?? null; $packageId = $metadata['package_id'] ?? null; if (!$tenantId || !$packageId) { Log::error('Missing metadata in PayPal order', ['order_id' => $orderId, 'metadata' => $metadata]); return; } $tenant = Tenant::find($tenantId); $package = Package::find($packageId); if (!$tenant || !$package) { Log::error('Tenant or package not found for PayPal order', ['order_id' => $orderId]); return; } DB::transaction(function () use ($tenant, $package, $orderId, $status) { // Idempotent check $existing = PackagePurchase::where('provider_id', $orderId)->first(); if ($existing) { return; } PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'provider_id' => $orderId, 'price' => $package->price, 'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription', 'purchased_at' => now(), 'status' => $status, 'metadata' => json_encode(['paypal_order' => $orderId, 'webhook' => true]), ]); // For trial: if first purchase and reseller, set trial $activePackages = TenantPackage::where('tenant_id', $tenant->id) ->where('active', true) ->count(); $expiresAt = now()->addYear(); if ($activePackages === 0 && $package->type === 'reseller_subscription') { $expiresAt = now()->addDays(14); // Trial } TenantPackage::updateOrCreate( ['tenant_id' => $tenant->id, 'package_id' => $package->id], [ 'price' => $package->price, 'purchased_at' => now(), 'active' => true, 'expires_at' => $expiresAt, ] ); $tenant->update(['subscription_status' => 'active']); }); Log::info('PayPal purchase processed via webhook', ['order_id' => $orderId, 'tenant_id' => $tenantId, 'status' => $status]); } catch (\Exception $e) { Log::error('Error processing PayPal order in webhook: ' . $e->getMessage(), ['order_id' => $orderId]); } } }