245 lines
6.9 KiB
PHP
245 lines
6.9 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;
|
|
|
|
public int $tries = 5;
|
|
|
|
public int $backoff = 60;
|
|
|
|
|
|
/**
|
|
* @param array<string, mixed> $payload
|
|
*/
|
|
public function __construct(array $payload, ?string $eventId = null)
|
|
{
|
|
$this->payload = $payload;
|
|
$this->eventId = $eventId !== '' ? $eventId : null;
|
|
$this->queue = config('services.revenuecat.queue', 'webhooks');
|
|
$this->onQueue($this->queue);
|
|
}
|
|
|
|
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);
|
|
|
|
$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(),
|
|
]);
|
|
});
|
|
|
|
$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;
|
|
}
|
|
}
|