298 lines
9.7 KiB
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;
|
|
}
|
|
} |