Files
fotospiel-app/app/Jobs/ProcessRevenueCatWebhook.php

249 lines
7.2 KiB
PHP

<?php
namespace App\Jobs;
use App\Models\EventPurchase;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class ProcessRevenueCatWebhook implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* @var array<string, mixed>
*/
private array $payload;
private ?string $eventId;
/**
* @param array<string, mixed> $payload
*/
public function __construct(array $payload, ?string $eventId = null)
{
$this->payload = $payload;
$this->eventId = $eventId !== '' ? $eventId : null;
}
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<string, int>
*/
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;
}
}