Harden credit flows and add RevenueCat webhook
This commit is contained in:
248
app/Jobs/ProcessRevenueCatWebhook.php
Normal file
248
app/Jobs/ProcessRevenueCatWebhook.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user