locateStripeSession($intent); if (! $session) { return false; } $lock = Cache::lock("checkout:webhook:stripe:{$intentId}", 30); if (! $lock->get()) { Log::info('[CheckoutWebhook] Stripe intent lock busy', [ 'intent_id' => $intentId, 'session_id' => $session->id, ]); return true; } try { $session->forceFill([ 'stripe_payment_intent_id' => $session->stripe_payment_intent_id ?: $intentId, 'provider' => CheckoutSession::PROVIDER_STRIPE, ])->save(); $metadata = [ 'stripe_last_event' => $eventType, 'stripe_last_event_id' => $event['id'] ?? null, 'stripe_intent_status' => $intent['status'] ?? null, 'stripe_last_update_at' => now()->toIso8601String(), ]; $this->mergeProviderMetadata($session, $metadata); return $this->applyStripeIntent($session, $eventType, $intent); } finally { $lock->release(); } } public function handlePaddleEvent(array $event): bool { $eventType = $event['event_type'] ?? null; $data = $event['data'] ?? []; if (! $eventType || ! is_array($data)) { return false; } if (Str::startsWith($eventType, 'subscription.')) { return $this->handlePaddleSubscriptionEvent($eventType, $data); } if ($this->isGiftVoucherEvent($data)) { if ($eventType === 'transaction.completed') { $this->giftVouchers->issueFromPaddle($data); return true; } return in_array($eventType, ['transaction.processing', 'transaction.created', 'transaction.failed', 'transaction.cancelled'], true); } $session = $this->locatePaddleSession($data); if (! $session) { Log::info('[CheckoutWebhook] Paddle session not resolved', [ 'event_type' => $eventType, 'transaction_id' => $data['id'] ?? null, ]); return false; } $transactionId = $data['id'] ?? $data['transaction_id'] ?? null; $lockKey = 'checkout:webhook:paddle:'.($transactionId ?: $session->id); $lock = Cache::lock($lockKey, 30); if (! $lock->get()) { Log::info('[CheckoutWebhook] Paddle lock busy', [ 'transaction_id' => $transactionId, 'session_id' => $session->id, ]); return true; } try { if ($transactionId) { $session->forceFill([ 'paddle_transaction_id' => $transactionId, 'provider' => CheckoutSession::PROVIDER_PADDLE, ])->save(); } elseif ($session->provider !== CheckoutSession::PROVIDER_PADDLE) { $session->forceFill(['provider' => CheckoutSession::PROVIDER_PADDLE])->save(); } $metadata = [ 'paddle_last_event' => $eventType, 'paddle_transaction_id' => $transactionId, 'paddle_status' => $data['status'] ?? null, 'paddle_last_update_at' => now()->toIso8601String(), ]; if (! empty($data['checkout_id'])) { $metadata['paddle_checkout_id'] = $data['checkout_id']; } $this->mergeProviderMetadata($session, $metadata); return $this->applyPaddleEvent($session, $eventType, $data); } finally { $lock->release(); } } protected function applyStripeIntent(CheckoutSession $session, string $eventType, array $intent): bool { switch ($eventType) { case 'payment_intent.processing': case 'payment_intent.amount_capturable_updated': $this->sessions->markProcessing($session, [ 'stripe_intent_status' => $intent['status'] ?? null, ]); return true; case 'payment_intent.requires_action': $reason = $intent['next_action']['type'] ?? 'requires_action'; $this->sessions->markRequiresCustomerAction($session, $reason); return true; case 'payment_intent.payment_failed': $failure = $intent['last_payment_error']['message'] ?? 'payment_failed'; $this->sessions->markFailed($session, $failure); return true; case 'payment_intent.succeeded': if ($session->status !== CheckoutSession::STATUS_COMPLETED) { $this->sessions->markProcessing($session, [ 'stripe_intent_status' => $intent['status'] ?? null, ]); $this->assignment->finalise($session, [ 'source' => 'stripe_webhook', 'stripe_payment_intent_id' => $intent['id'] ?? null, 'stripe_charge_id' => $this->extractStripeChargeId($intent), ]); $this->sessions->markCompleted($session, now()); } return true; default: return false; } } protected function applyPaddleEvent(CheckoutSession $session, string $eventType, array $data): bool { $status = strtolower((string) ($data['status'] ?? '')); switch ($eventType) { case 'transaction.created': case 'transaction.processing': $this->sessions->markProcessing($session, [ 'paddle_status' => $status ?: null, ]); return true; case 'transaction.completed': if ($session->status !== CheckoutSession::STATUS_COMPLETED) { $this->sessions->markProcessing($session, [ 'paddle_status' => $status ?: 'completed', ]); $this->assignment->finalise($session, [ 'source' => 'paddle_webhook', 'provider' => CheckoutSession::PROVIDER_PADDLE, 'provider_reference' => $data['id'] ?? null, 'payload' => $data, ]); $this->sessions->markCompleted($session, now()); $this->couponRedemptions->recordSuccess($session, $data); } return true; case 'transaction.failed': case 'transaction.cancelled': $reason = $status ?: ($eventType === 'transaction.failed' ? 'paddle_failed' : 'paddle_cancelled'); $this->sessions->markFailed($session, $reason); $this->couponRedemptions->recordFailure($session, $reason); return true; default: return false; } } protected function handlePaddleSubscriptionEvent(string $eventType, array $data): bool { $subscriptionId = $data['id'] ?? null; if (! $subscriptionId) { return false; } $metadata = $data['metadata'] ?? []; $tenant = $this->resolveTenantFromSubscription($data, $metadata, $subscriptionId); if (! $tenant) { Log::info('[CheckoutWebhook] Paddle subscription tenant not resolved', [ 'subscription_id' => $subscriptionId, ]); return false; } $package = $this->resolvePackageFromSubscription($data, $metadata, $subscriptionId); if (! $package) { Log::info('[CheckoutWebhook] Paddle subscription package not resolved', [ 'subscription_id' => $subscriptionId, ]); return false; } $status = strtolower((string) ($data['status'] ?? '')); $expiresAt = $this->resolveSubscriptionExpiry($data); $startedAt = $this->resolveSubscriptionStart($data); $tenantPackage = TenantPackage::firstOrNew([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, ]); $tenantPackage->fill([ 'paddle_subscription_id' => $subscriptionId, 'price' => $package->price, ]); $tenantPackage->expires_at = $expiresAt ?? $tenantPackage->expires_at ?? $startedAt?->copy()->addYear(); $tenantPackage->purchased_at = $tenantPackage->purchased_at ?? $tenant->purchases()->where('package_id', $package->id)->latest('purchased_at')->value('purchased_at') ?? $startedAt; $tenantPackage->active = $this->isSubscriptionActive($status); $tenantPackage->save(); if ($eventType === 'subscription.cancelled' || $eventType === 'subscription.paused') { $tenantPackage->forceFill(['active' => false])->save(); } $tenant->forceFill([ 'subscription_status' => $this->mapSubscriptionStatus($status), 'subscription_expires_at' => $expiresAt, 'paddle_customer_id' => $tenant->paddle_customer_id ?: ($data['customer_id'] ?? null), ])->save(); Log::info('[CheckoutWebhook] Paddle subscription event processed', [ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'subscription_id' => $subscriptionId, 'event_type' => $eventType, 'status' => $status, ]); return true; } protected function resolveTenantFromSubscription(array $data, array $metadata, string $subscriptionId): ?Tenant { if (isset($metadata['tenant_id'])) { $tenant = Tenant::find((int) $metadata['tenant_id']); if ($tenant) { return $tenant; } } $customerId = $data['customer_id'] ?? null; if ($customerId) { $tenant = Tenant::where('paddle_customer_id', $customerId)->first(); if ($tenant) { return $tenant; } } $subscription = $this->paddleSubscriptions->retrieve($subscriptionId); $customerId = Arr::get($subscription, 'data.customer_id'); if ($customerId) { return Tenant::where('paddle_customer_id', $customerId)->first(); } return null; } protected function resolvePackageFromSubscription(array $data, array $metadata, string $subscriptionId): ?Package { if (isset($metadata['package_id'])) { $package = Package::withTrashed()->find((int) $metadata['package_id']); if ($package) { return $package; } } $priceId = Arr::get($data, 'items.0.price_id') ?? Arr::get($data, 'items.0.price.id'); if ($priceId) { $package = Package::withTrashed()->where('paddle_price_id', $priceId)->first(); if ($package) { return $package; } } $subscription = $this->paddleSubscriptions->retrieve($subscriptionId); $priceId = Arr::get($subscription, 'data.items.0.price_id') ?? Arr::get($subscription, 'data.items.0.price.id'); if ($priceId) { return Package::withTrashed()->where('paddle_price_id', $priceId)->first(); } return null; } protected function resolveSubscriptionExpiry(array $data): ?Carbon { $nextBilling = Arr::get($data, 'next_billing_date') ?? Arr::get($data, 'next_payment_date'); if ($nextBilling) { return Carbon::parse($nextBilling); } $endsAt = Arr::get($data, 'billing_period_ends_at') ?? Arr::get($data, 'pays_out_at'); return $endsAt ? Carbon::parse($endsAt) : null; } protected function resolveSubscriptionStart(array $data): Carbon { $created = Arr::get($data, 'created_at') ?? Arr::get($data, 'activated_at'); return $created ? Carbon::parse($created) : now(); } protected function isSubscriptionActive(string $status): bool { return in_array($status, ['active', 'trialing'], true); } protected function mapSubscriptionStatus(string $status): string { return match ($status) { 'active', 'trialing' => 'active', 'paused' => 'suspended', 'cancelled', 'past_due', 'halted' => 'expired', default => 'free', }; } protected function mergeProviderMetadata(CheckoutSession $session, array $data): void { $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); $session->save(); } protected function locateStripeSession(array $intent): ?CheckoutSession { $intentId = $intent['id'] ?? null; if ($intentId) { $session = CheckoutSession::query() ->where('stripe_payment_intent_id', $intentId) ->first(); if ($session) { return $session; } } $metadata = $intent['metadata'] ?? []; $sessionId = $metadata['checkout_session_id'] ?? null; if ($sessionId) { return CheckoutSession::find($sessionId); } return null; } protected function isGiftVoucherEvent(array $data): bool { $metadata = $data['metadata'] ?? []; $type = is_array($metadata) ? ($metadata['type'] ?? $metadata['kind'] ?? $metadata['category'] ?? null) : null; if ($type && in_array(strtolower($type), ['gift_card', 'gift_voucher'], true)) { return true; } $priceId = $data['price_id'] ?? Arr::get($metadata, 'paddle_price_id'); $tiers = collect(config('gift-vouchers.tiers', [])) ->pluck('paddle_price_id') ->filter() ->all(); return $priceId && in_array($priceId, $tiers, true); } protected function locatePaddleSession(array $data): ?CheckoutSession { $metadata = $data['metadata'] ?? []; if (is_array($metadata)) { $sessionId = $metadata['checkout_session_id'] ?? null; if ($sessionId && $session = CheckoutSession::find($sessionId)) { return $session; } $tenantId = $metadata['tenant_id'] ?? null; $packageId = $metadata['package_id'] ?? null; if ($tenantId && $packageId) { $session = CheckoutSession::query() ->where('tenant_id', $tenantId) ->where('package_id', $packageId) ->whereNotIn('status', [CheckoutSession::STATUS_COMPLETED, CheckoutSession::STATUS_CANCELLED]) ->latest() ->first(); if ($session) { return $session; } } } $checkoutId = $data['checkout_id'] ?? Arr::get($data, 'details.checkout_id'); if ($checkoutId) { return CheckoutSession::query() ->where('provider_metadata->paddle_checkout_id', $checkoutId) ->first(); } return null; } protected function extractStripeChargeId(array $intent): ?string { $charges = $intent['charges']['data'] ?? null; if (is_array($charges) && count($charges) > 0) { return $charges[0]['id'] ?? null; } return null; } }