$payload */ public function recordReceived(array $payload): void { $context = $this->buildContext($payload); $tracked = in_array($context['event_type'], self::TRACKED_EVENT_TYPES, true); $cacheKeys = []; if ($tracked && $context['user'] instanceof User) { $cacheKeys = [ 'academy.billing.account.'.$context['user']->id, 'academy.billing.pricing.'.$context['user']->id, ]; foreach ($cacheKeys as $cacheKey) { Cache::forget($cacheKey); } } $event = $this->persistEvent($context, [ 'received' => true, 'received_at' => now()->toISOString(), 'tracked' => $tracked, 'action' => $tracked ? 'received_for_cashier_processing' : 'ignored_untracked_event', 'user_resolved' => $context['user'] instanceof User, 'cache_cleared' => $cacheKeys !== [], 'cache_keys' => $cacheKeys, 'status' => $context['object']['status'] ?? null, 'mode' => $context['object']['mode'] ?? null, 'amount_total' => $context['object']['amount_total'] ?? null, 'currency' => $context['object']['currency'] ?? null, 'price_ids' => $this->extractPriceIds($context['object']), ]); Log::info('academy.stripe.webhook.received', [ 'stripe_event_id' => $context['event_id'], 'event_type' => $context['event_type'], 'tracked' => $tracked, 'user_id' => $context['user']?->id, 'academy_plan' => $context['plan']['key'] ?? null, 'academy_tier' => $context['plan']['tier'] ?? null, 'audit_event_id' => $event->id, ]); } /** * @param array $payload */ public function recordHandled(array $payload): void { $context = $this->buildContext($payload); $localSubscription = $this->resolveLocalSubscription($context['subscription_id'], $context['user']); $outcome = $localSubscription instanceof Subscription ? 'local_subscription_synced' : 'handled_without_local_subscription_change'; $event = $this->persistEvent($context, [ 'handled' => true, 'handled_at' => now()->toISOString(), 'outcome' => $outcome, 'local_subscription_found' => $localSubscription instanceof Subscription, 'local_subscription_status' => $localSubscription?->stripe_status, 'local_subscription_active' => $localSubscription?->active(), 'local_subscription_on_grace_period' => $localSubscription?->onGracePeriod(), 'local_price_ids' => $localSubscription instanceof Subscription ? $localSubscription->items->pluck('stripe_price')->filter()->values()->all() : [], ]); Log::info('academy.stripe.webhook.handled', [ 'stripe_event_id' => $context['event_id'], 'event_type' => $context['event_type'], 'user_id' => $context['user']?->id, 'academy_plan' => $context['plan']['key'] ?? null, 'academy_tier' => $context['plan']['tier'] ?? null, 'outcome' => $outcome, 'audit_event_id' => $event->id, ]); } /** * @param array $payload * @return array{event_id:string,event_type:string,object:array,customer_id:?string,subscription_id:?string,plan:?array,user:?User} */ private function buildContext(array $payload): array { $eventType = trim((string) ($payload['type'] ?? '')); $object = is_array($payload['data']['object'] ?? null) ? $payload['data']['object'] : []; $customerId = $this->extractCustomerId($object); $subscriptionId = $this->extractSubscriptionId($object); $plan = $this->resolvePlan($object); $user = $this->resolveUser($customerId, $subscriptionId, $object); return [ 'event_id' => trim((string) ($payload['id'] ?? '')), 'event_type' => $eventType, 'object' => $object, 'customer_id' => $customerId, 'subscription_id' => $subscriptionId, 'plan' => $plan, 'user' => $user, ]; } /** * @param array $context * @param array $summary */ private function persistEvent(array $context, array $summary): AcademyBillingEvent { $eventId = $context['event_id']; $event = $eventId !== '' ? AcademyBillingEvent::query()->firstOrNew(['stripe_event_id' => $eventId]) : new AcademyBillingEvent(); $existingSummary = is_array($event->payload_summary) ? $event->payload_summary : []; $event->fill([ 'user_id' => $context['user']?->id, 'stripe_event_id' => $eventId !== '' ? $eventId : null, 'stripe_customer_id' => $context['customer_id'], 'stripe_subscription_id' => $context['subscription_id'], 'event_type' => $context['event_type'] !== '' ? $context['event_type'] : 'unknown', 'academy_tier' => $context['plan']['tier'] ?? null, 'academy_plan' => $context['plan']['key'] ?? null, 'payload_summary' => array_merge($existingSummary, $summary), 'processed_at' => now(), ]); $event->save(); return $event; } /** * @param array $object * @return array|null */ private function resolvePlan(array $object): ?array { $metadataPlan = trim((string) Arr::get($object, 'metadata.academy_plan', '')); if ($metadataPlan !== '') { return $this->plans->plan($metadataPlan); } foreach ($this->extractPriceIds($object) as $priceId) { $plan = $this->plans->planForPriceId($priceId); if ($plan !== null) { return $plan; } } return null; } /** * @param array $object * @return list */ private function extractPriceIds(array $object): array { $priceIds = []; foreach ((array) Arr::get($object, 'items.data', []) as $item) { if (! is_array($item)) { continue; } $priceId = trim((string) Arr::get($item, 'price.id', '')); if ($priceId !== '') { $priceIds[] = $priceId; } } $lineItemPriceId = trim((string) Arr::get($object, 'display_items.0.price.id', '')); if ($lineItemPriceId !== '') { $priceIds[] = $lineItemPriceId; } return array_values(array_unique($priceIds)); } /** * @param array $object */ private function extractCustomerId(array $object): ?string { $value = trim((string) ($object['customer'] ?? '')); return $value !== '' ? $value : null; } /** * @param array $object */ private function extractSubscriptionId(array $object): ?string { $subscriptionId = trim((string) ($object['id'] ?? '')); if (str_starts_with($subscriptionId, 'sub_')) { return $subscriptionId; } $nested = trim((string) ($object['subscription'] ?? '')); return $nested !== '' ? $nested : null; } /** * @param array $object */ private function resolveUser(?string $customerId, ?string $subscriptionId, array $object): ?User { $metadataUserId = (int) Arr::get($object, 'metadata.user_id', 0); if ($metadataUserId > 0) { return User::query()->find($metadataUserId); } if ($customerId !== null) { $user = User::query()->where('stripe_id', $customerId)->first(); if ($user instanceof User) { return $user; } } if ($subscriptionId !== null) { $subscription = Subscription::query()->where('stripe_id', $subscriptionId)->first(); if ($subscription !== null && $subscription->user instanceof User) { return $subscription->user; } } return null; } private function resolveLocalSubscription(?string $subscriptionId, ?User $user): ?Subscription { if ($subscriptionId !== null) { $subscription = Subscription::query()->where('stripe_id', $subscriptionId)->with('items')->first(); if ($subscription instanceof Subscription) { return $subscription; } } if (! $user instanceof User) { return null; } $subscription = $user->subscription($this->plans->subscriptionName()); return $subscription instanceof Subscription ? $subscription->loadMissing('items') : null; } }