handleLemonSqueezySubscriptionEvent($eventType, $data, $event); } if ($this->isGiftVoucherEvent($event)) { if ($eventType === 'order_created') { $this->giftVouchers->issueFromLemonSqueezy($event); return true; } return in_array($eventType, ['order_created', 'order_refunded', 'order_payment_failed', 'order_updated'], true); } $session = $this->locateLemonSqueezySession($event); if (! $session) { Log::info('[CheckoutWebhook] Lemon Squeezy session not resolved', [ 'event_type' => $eventType, 'order_id' => $data['id'] ?? null, ]); return false; } $orderId = $data['id'] ?? null; $lockKey = 'checkout:webhook:lemonsqueezy:'.($orderId ?: $session->id); $lock = Cache::lock($lockKey, 30); if (! $lock->get()) { Log::info('[CheckoutWebhook] Lemon Squeezy lock busy', [ 'order_id' => $orderId, 'session_id' => $session->id, ]); return true; } try { if ($orderId) { $session->forceFill([ 'lemonsqueezy_order_id' => $orderId, 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, ])->save(); } elseif ($session->provider !== CheckoutSession::PROVIDER_LEMONSQUEEZY) { $session->forceFill(['provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY])->save(); } $metadata = [ 'lemonsqueezy_last_event' => $eventType, 'lemonsqueezy_order_id' => $orderId, 'lemonsqueezy_status' => data_get($data, 'attributes.status'), 'lemonsqueezy_last_update_at' => now()->toIso8601String(), ]; $checkoutId = data_get($data, 'attributes.checkout_id') ?? data_get($event, 'meta.custom_data.checkout_id'); if (! empty($checkoutId)) { $metadata['lemonsqueezy_checkout_id'] = $checkoutId; } $this->mergeProviderMetadata($session, $metadata); $customerId = data_get($data, 'attributes.customer_id') ?? data_get($data, 'relationships.customer.data.id'); if ($customerId && $session->tenant && ! $session->tenant->lemonsqueezy_customer_id) { $session->tenant->forceFill([ 'lemonsqueezy_customer_id' => (string) $customerId, ])->save(); } return $this->applyLemonSqueezyEvent($session, $eventType, $data, $event); } finally { $lock->release(); } } protected function applyLemonSqueezyEvent(CheckoutSession $session, string $eventType, array $data, array $event): bool { $status = Str::lower((string) data_get($data, 'attributes.status', '')); switch ($eventType) { case 'order_created': case 'order_updated': $this->syncSessionTotals($session, $data); if ($status === 'paid') { if ($session->status !== CheckoutSession::STATUS_COMPLETED) { $this->sessions->markProcessing($session, [ 'lemonsqueezy_status' => $status ?: 'paid', ]); $this->assignment->finalise($session, [ 'source' => 'lemonsqueezy_webhook', 'provider' => CheckoutSession::PROVIDER_LEMONSQUEEZY, 'provider_reference' => $data['id'] ?? null, 'payload' => $data, ]); $this->sessions->markCompleted($session, now()); $this->couponRedemptions->recordSuccess($session, $data); } } else { $this->sessions->markProcessing($session, [ 'lemonsqueezy_status' => $status ?: null, ]); } return true; case 'order_payment_failed': $reason = $status ?: 'lemonsqueezy_failed'; $this->sessions->markFailed($session, $reason); $this->couponRedemptions->recordFailure($session, $reason); return true; case 'order_refunded': $this->sessions->markFailed($session, 'lemonsqueezy_refunded'); $this->couponRedemptions->recordFailure($session, 'lemonsqueezy_refunded'); return true; default: return false; } } protected function syncSessionTotals(CheckoutSession $session, array $data): void { $totals = $this->normalizeLemonSqueezyTotals($data); if ($totals === []) { return; } $updates = []; if (array_key_exists('subtotal', $totals)) { $updates['amount_subtotal'] = $totals['subtotal']; } if (array_key_exists('discount', $totals)) { $updates['amount_discount'] = $totals['discount']; } if (array_key_exists('total', $totals)) { $updates['amount_total'] = $totals['total']; } if (! empty($totals['currency'])) { $updates['currency'] = $totals['currency']; } if ($updates !== []) { $session->forceFill($updates)->save(); } $this->mergeProviderMetadata($session, [ 'lemonsqueezy_totals' => $totals, ]); } /** * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} */ protected function normalizeLemonSqueezyTotals(array $data): array { $attributes = Arr::get($data, 'attributes', []); $currency = Arr::get($attributes, 'currency'); $subtotal = $this->convertMinorAmount(Arr::get($attributes, 'subtotal')); $discount = $this->convertMinorAmount(Arr::get($attributes, 'discount_total')); $tax = $this->convertMinorAmount(Arr::get($attributes, 'tax')); $total = $this->convertMinorAmount(Arr::get($attributes, 'total')); return array_filter([ 'currency' => $currency ? strtoupper((string) $currency) : null, 'subtotal' => $subtotal, 'discount' => $discount, 'tax' => $tax, 'total' => $total, ], static fn ($value) => $value !== null); } protected function convertMinorAmount(mixed $value): ?float { if ($value === null || $value === '') { return null; } if (is_array($value) && isset($value['amount'])) { $value = $value['amount']; } if (! is_numeric($value)) { return null; } return round(((float) $value) / 100, 2); } protected function handleLemonSqueezySubscriptionEvent(string $eventType, array $data, array $event): bool { $subscriptionId = $data['id'] ?? null; if (! $subscriptionId) { return false; } $customData = $this->extractCustomData($event); $tenant = $this->resolveTenantFromSubscription($data, $customData, $subscriptionId); if (! $tenant) { Log::info('[CheckoutWebhook] Lemon Squeezy subscription tenant not resolved', [ 'subscription_id' => $subscriptionId, ]); return false; } $package = $this->resolvePackageFromSubscription($data, $customData, $subscriptionId); if (! $package) { Log::info('[CheckoutWebhook] Lemon Squeezy subscription package not resolved', [ 'subscription_id' => $subscriptionId, ]); return false; } $status = Str::lower((string) Arr::get($data, 'attributes.status', '')); $expiresAt = $this->resolveSubscriptionExpiry($data); $startedAt = $this->resolveSubscriptionStart($data); $tenantPackage = TenantPackage::firstOrNew([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, ]); $tenantPackage->fill([ 'lemonsqueezy_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 (in_array($eventType, ['subscription_cancelled', 'subscription_expired', 'subscription_paused'], true)) { $tenantPackage->forceFill(['active' => false])->save(); } $tenant->forceFill([ 'subscription_status' => $this->mapSubscriptionStatus($status), 'subscription_expires_at' => $expiresAt, 'lemonsqueezy_customer_id' => $tenant->lemonsqueezy_customer_id ?: Arr::get($data, 'attributes.customer_id'), ])->save(); Log::info('[CheckoutWebhook] Lemon Squeezy 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 = Arr::get($data, 'attributes.customer_id') ?? Arr::get($data, 'relationships.customer.data.id'); if ($customerId) { $tenant = Tenant::where('lemonsqueezy_customer_id', $customerId)->first(); if ($tenant) { return $tenant; } } $subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId); $customerId = Arr::get($subscription, 'attributes.customer_id') ?? Arr::get($subscription, 'relationships.customer.data.id'); if ($customerId) { return Tenant::where('lemonsqueezy_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; } } $variantId = Arr::get($data, 'attributes.variant_id') ?? Arr::get($data, 'relationships.variant.data.id'); if ($variantId) { $package = Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first(); if ($package) { return $package; } } $subscription = $this->lemonsqueezySubscriptions->retrieve($subscriptionId); $variantId = Arr::get($subscription, 'attributes.variant_id') ?? Arr::get($subscription, 'relationships.variant.data.id'); if ($variantId) { return Package::withTrashed()->where('lemonsqueezy_variant_id', $variantId)->first(); } return null; } protected function resolveSubscriptionExpiry(array $data): ?Carbon { $nextBilling = Arr::get($data, 'attributes.renews_at'); if ($nextBilling) { return Carbon::parse($nextBilling); } $endsAt = Arr::get($data, 'attributes.ends_at'); return $endsAt ? Carbon::parse($endsAt) : null; } protected function resolveSubscriptionStart(array $data): Carbon { $created = Arr::get($data, 'attributes.created_at'); return $created ? Carbon::parse($created) : now(); } protected function isSubscriptionActive(string $status): bool { return in_array($status, ['active', 'on_trial'], true); } protected function mapSubscriptionStatus(string $status): string { return match ($status) { 'active', 'on_trial' => 'active', 'past_due', 'unpaid', 'paused' => 'suspended', 'cancelled', 'expired' => 'expired', default => 'free', }; } protected function mergeProviderMetadata(CheckoutSession $session, array $data): void { $session->provider_metadata = array_merge($session->provider_metadata ?? [], $data); $session->save(); } protected function isGiftVoucherEvent(array $event): bool { $metadata = $this->extractCustomData($event); $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; } $variantId = data_get($event, 'data.attributes.variant_id') ?? Arr::get($metadata, 'lemonsqueezy_variant_id'); $tiers = collect(config('gift-vouchers.tiers', [])) ->pluck('lemonsqueezy_variant_id') ->filter() ->all(); return $variantId && in_array($variantId, $tiers, true); } protected function locateLemonSqueezySession(array $event): ?CheckoutSession { $metadata = $this->extractCustomData($event); 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_get($event, 'data.attributes.checkout_id') ?? Arr::get($metadata, 'lemonsqueezy_checkout_id') ?? Arr::get($metadata, 'checkout_id'); if ($checkoutId) { return CheckoutSession::query() ->where('provider_metadata->lemonsqueezy_checkout_id', $checkoutId) ->first(); } return null; } /** * @param array $data * @return array */ protected function extractCustomData(array $data): array { $customData = []; if (isset($data['meta']['custom_data']) && is_array($data['meta']['custom_data'])) { $customData = $data['meta']['custom_data']; } if (isset($data['attributes']['custom_data']) && is_array($data['attributes']['custom_data'])) { $customData = array_merge($customData, $data['attributes']['custom_data']); } if (isset($data['custom_data']) && is_array($data['custom_data'])) { $customData = array_merge($customData, $data['custom_data']); } if (isset($data['customData']) && is_array($data['customData'])) { $customData = array_merge($customData, $data['customData']); } if (isset($data['metadata']) && is_array($data['metadata'])) { $customData = array_merge($customData, $data['metadata']); } return $customData; } }