Files
SkinbaseNova/app/Services/Academy/AcademyStripeWebhookAuditService.php

298 lines
9.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Academy;
use App\Models\AcademyBillingEvent;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Laravel\Cashier\Subscription;
final class AcademyStripeWebhookAuditService
{
private const TRACKED_EVENT_TYPES = [
'checkout.session.completed',
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'invoice.payment_succeeded',
'invoice.payment_failed',
'invoice.payment_action_required',
];
public function __construct(
private readonly AcademyBillingPlanService $plans,
) {}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $payload
* @return array{event_id:string,event_type:string,object:array<string,mixed>,customer_id:?string,subscription_id:?string,plan:?array<string,mixed>,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<string, mixed> $context
* @param array<string, mixed> $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<string, mixed> $object
* @return array<string, mixed>|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<string, mixed> $object
* @return list<string>
*/
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<string, mixed> $object
*/
private function extractCustomerId(array $object): ?string
{
$value = trim((string) ($object['customer'] ?? ''));
return $value !== '' ? $value : null;
}
/**
* @param array<string, mixed> $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<string, mixed> $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;
}
}