*/ private array $payload; private ?string $eventId; public int $tries = 5; public int $backoff = 60; /** * @param array $payload */ public function __construct(array $payload, ?string $eventId = null) { $this->payload = $payload; $this->eventId = $eventId !== '' ? $eventId : null; $this->queue = config('services.revenuecat.queue', 'webhooks'); $this->onQueue($this->queue); } public function handle(): void { $appUserId = $this->value('event.app_user_id') ?? $this->value('subscriber.app_user_id'); if (! is_string($appUserId) || $appUserId === '') { Log::warning('RevenueCat webhook missing app_user_id', [ 'event_id' => $this->eventId, ]); return; } $tenant = $this->resolveTenant($appUserId); if (! $tenant) { Log::warning('RevenueCat webhook tenant not found', [ 'event_id' => $this->eventId, 'app_user_id' => $appUserId, ]); return; } $productId = $this->value('event.product_id') ?? $this->value('event.entitlement_id'); $credits = $this->mapCreditsFromProduct($productId); if ($credits <= 0) { Log::info('RevenueCat webhook ignored due to unmapped product', [ 'event_id' => $this->eventId, 'product_id' => $productId, ]); return; } $transactionId = $this->value('event.transaction_id') ?? $this->value('event.id') ?? $this->eventId ?? (string) Str::uuid(); if (EventPurchase::where('provider', 'revenuecat') ->where('external_receipt_id', $transactionId) ->exists()) { return; } $amount = (float) ($this->value('event.price') ?? 0); $currency = strtoupper((string) ($this->value('event.currency') ?? 'EUR')); DB::transaction(function () use ($tenant, $credits, $transactionId, $productId, $amount, $currency) { $tenant->refresh(); $purchase = EventPurchase::create([ 'tenant_id' => $tenant->id, 'events_purchased' => $credits, 'amount' => $amount, 'currency' => $currency, 'provider' => 'revenuecat', 'external_receipt_id' => $transactionId, 'status' => 'completed', 'purchased_at' => now(), ]); $note = sprintf('RevenueCat product: %s', $productId ?? 'unknown'); $tenant->incrementCredits($credits, 'purchase', $note, $purchase->id); }); $tenant->refresh(); $this->updateSubscriptionStatus($tenant); Log::info('RevenueCat webhook processed', [ 'event_id' => $this->eventId, 'tenant_id' => $tenant->id, 'product_id' => $productId, 'credits' => $credits, ]); } private function updateSubscriptionStatus(Tenant $tenant): void { $expirationMs = $this->value('event.expiration_at_ms'); $expirationIso = $this->value('event.expiration_at'); $expiresAt = null; if (is_numeric($expirationMs)) { $expiresAt = Carbon::createFromTimestampMs((int) $expirationMs, 'UTC'); } elseif (is_string($expirationIso) && $expirationIso !== '') { try { $expiresAt = Carbon::parse($expirationIso); } catch (\Throwable $e) { $expiresAt = null; } } if (! $expiresAt instanceof Carbon) { return; } $expiresAt = $expiresAt->copy()->setTimezone('UTC')->floorSecond(); $tier = $expiresAt->isFuture() ? 'pro' : 'free'; $tenant->update([ 'subscription_tier' => $tier, 'subscription_expires_at' => $expiresAt, ]); } private function resolveTenant(string $appUserId): ?Tenant { $prefix = (string) config('services.revenuecat.app_user_prefix', 'tenant'); $lower = strtolower($appUserId); if (is_numeric($appUserId)) { $byId = Tenant::find((int) $appUserId); if ($byId) { return $byId; } } foreach ([':', '-', '_'] as $delimiter) { $needle = strtolower($prefix) . $delimiter; if (str_starts_with($lower, $needle)) { $candidate = substr($appUserId, strlen($needle)); if (is_numeric($candidate)) { $byNumeric = Tenant::find((int) $candidate); if ($byNumeric) { return $byNumeric; } } $bySlug = Tenant::where('slug', $candidate)->first(); if ($bySlug) { return $bySlug; } } } return Tenant::where('slug', $appUserId)->first(); } private function mapCreditsFromProduct(?string $productId): int { if (! is_string($productId) || $productId === '') { return 0; } $mappings = $this->productMappings(); if (array_key_exists($productId, $mappings)) { return (int) $mappings[$productId]; } return 0; } /** * @return array */ private function productMappings(): array { $raw = (string) config('services.revenuecat.product_mappings', ''); if ($raw === '') { return []; } $decoded = json_decode($raw, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { $normalized = []; foreach ($decoded as $key => $value) { if (! is_scalar($value)) { continue; } $normalized[(string) $key] = (int) $value; } return $normalized; } $mappings = []; foreach (explode(',', $raw) as $pair) { $pair = trim($pair); if ($pair === '') { continue; } [$key, $value] = array_pad(explode(':', $pair, 2), 2, null); if ($key === null || $value === null) { continue; } $mappings[trim($key)] = (int) trim($value); } return $mappings; } private function value(string $path, $default = null) { $segments = explode('.', $path); $value = $this->payload; foreach ($segments as $segment) { if (! is_array($value) || ! array_key_exists($segment, $value)) { return $default; } $value = $value[$segment]; } return $value; } }