feat: integrate login/registration into PurchaseWizard

This commit is contained in:
Codex Agent
2025-10-04 21:38:03 +02:00
parent 3c0bbb688b
commit fdaa2bec62
52 changed files with 1477 additions and 732 deletions

View File

@@ -2,137 +2,263 @@
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use PayPal\PayPalHttp\Client;
use PayPal\Checkout\Orders\OrdersGetRequest;
use App\Models\TenantPackage;
use PayPal\Environment\SandboxEnvironment;
use PayPal\Environment\LiveEnvironment;
use PayPal\PayPalClient;
use PayPal\Webhook\Webhook;
use PayPal\Webhook\VerifyWebhookSignature;
use App\Models\PackagePurchase;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\Tenant;
use Exception;
use App\Models\Package;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class PayPalWebhookController extends Controller
{
public function handle(Request $request)
public function verify(Request $request): JsonResponse
{
$input = $request->all();
$ipnMessage = $input['ipn_track_id'] ?? null;
$verification = $this->verifyIPN($request);
$request->validate([
'webhook_id' => 'required|string',
'webhook_event' => 'required|array',
]);
if (!$verification) {
Log::warning('PayPal IPN verification failed', ['ipn_track_id' => $ipnMessage]);
return response('Invalid IPN', 400);
}
$webhookId = $request->webhook_id;
$event = $request->webhook_event;
$eventType = $input['payment_status'] ?? null;
$customId = $input['custom'] ?? null;
$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'));
if (!$eventType || !$customId) {
Log::warning('Missing event type or custom ID in PayPal IPN', ['input' => $input]);
return response('Invalid data', 400);
}
$client = PayPalClient::client($environment);
if ($eventType !== 'Completed') {
Log::info('Non-completed PayPal event ignored', ['event' => $eventType, 'ipn_track_id' => $ipnMessage]);
return response('OK', 200);
}
$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);
if (!$metadata || !isset($metadata['tenant_id'], $metadata['package_id'])) {
throw new Exception('Invalid metadata');
$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;
}
$tenant = Tenant::find($metadata['tenant_id']);
$package = Package::find($metadata['package_id']);
$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) {
throw new Exception('Tenant or package not found');
Log::error('Tenant or package not found for PayPal order', ['order_id' => $orderId]);
return;
}
// Idempotent: Check if already processed
$existingPurchase = PackagePurchase::where('tenant_id', $tenant->id)
->where('package_id', $package->id)
->where('provider_id', 'paypal')
->where('purchased_at', '>=', now()->subDay()) // Recent to avoid duplicates
->first();
DB::transaction(function () use ($tenant, $package, $orderId, $status) {
// Idempotent check
$existing = PackagePurchase::where('provider_id', $orderId)->first();
if ($existing) {
return;
}
if ($existingPurchase) {
Log::info('PayPal purchase already processed', ['purchase_id' => $existingPurchase->id]);
return response('OK', 200);
}
// Activate package
TenantPackage::updateOrCreate(
[
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
],
[
'active' => true,
'provider_id' => $orderId,
'price' => $package->price,
'type' => $package->type === 'endcustomer' ? 'endcustomer_event' : 'reseller_subscription',
'purchased_at' => now(),
'expires_at' => now()->addYear(),
]
);
'status' => $status,
'metadata' => json_encode(['paypal_order' => $orderId, 'webhook' => true]),
]);
$user = User::find($metadata['user_id'] ?? null);
if ($user) {
$user->update(['role' => 'tenant_admin']);
}
// For trial: if first purchase and reseller, set trial
$activePackages = TenantPackage::where('tenant_id', $tenant->id)
->where('active', true)
->count();
// Log purchase
PackagePurchase::create([
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'provider_id' => 'paypal',
'price' => $package->price,
'type' => $package->type,
'purchased_at' => now(),
'refunded' => false,
]);
$expiresAt = now()->addYear();
if ($activePackages === 0 && $package->type === 'reseller_subscription') {
$expiresAt = now()->addDays(14); // Trial
}
$tenant->update(['subscription_status' => 'active']);
TenantPackage::updateOrCreate(
['tenant_id' => $tenant->id, 'package_id' => $package->id],
[
'price' => $package->price,
'purchased_at' => now(),
'active' => true,
'expires_at' => $expiresAt,
]
);
Log::info('PayPal purchase processed successfully', [
'tenant_id' => $tenant->id,
'package_id' => $package->id,
'ipn_track_id' => $ipnMessage,
]);
$tenant->update(['subscription_status' => 'active']);
});
return response('OK', 200);
} catch (Exception $e) {
Log::error('PayPal webhook processing error: ' . $e->getMessage(), [
'input' => $input,
'ipn_track_id' => $ipnMessage,
]);
return response('Error', 500);
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]);
}
}
private function verifyIPN(Request $request)
{
$rawBody = $request->getContent();
$params = $request->all();
// For sandbox, post to PayPal verify endpoint
$verifyParams = array_merge($params, ['cmd' => '_notify-validate']);
$response = file_get_contents('https://ipnpb.paypal.com/cgi-bin/webscr', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => 'Content-type: application/x-www-form-urlencoded',
'content' => http_build_query($verifyParams),
],
]));
if ($response === false) {
Log::error('PayPal IPN verification request failed');
return false;
}
return trim($response) === 'VERIFIED';
}
}