Implement academy analytics, billing, and web stories updates
This commit is contained in:
298
app/Services/Academy/AcademyStripeWebhookAuditService.php
Normal file
298
app/Services/Academy/AcademyStripeWebhookAuditService.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user