Harden credit flows and add RevenueCat webhook

This commit is contained in:
2025-09-25 14:05:58 +02:00
parent 9248d7a3f5
commit 215d19f07e
18 changed files with 804 additions and 190 deletions

View File

@@ -4,26 +4,24 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest;
use Illuminate\Support\Str;
use App\Http\Resources\Tenant\EventResource;
use App\Models\Event;
use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class EventController extends Controller
{
/**
* Display a listing of the tenant's events.
*/
public function index(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
if (!$tenantId) {
if (! $tenantId) {
throw ValidationException::withMessages([
'tenant_id' => 'Tenant ID not found in request context.',
]);
@@ -33,7 +31,6 @@ class EventController extends Controller
->with(['eventType', 'photos'])
->orderBy('created_at', 'desc');
// Apply filters
if ($request->has('status')) {
$query->where('status', $request->status);
}
@@ -42,55 +39,107 @@ class EventController extends Controller
$query->where('event_type_id', $request->type_id);
}
// Pagination
$perPage = $request->get('per_page', 15);
$events = $query->paginate($perPage);
$events = $query->paginate($request->get('per_page', 15));
return EventResource::collection($events);
}
/**
* Store a newly created event in storage.
*/
public function store(EventStoreRequest $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Check credits balance
$tenant = Tenant::findOrFail($tenantId);
if ($tenant->event_credits_balance <= 0) {
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$tenantId = $request->attributes->get('tenant_id');
$tenant = Tenant::findOrFail($tenantId);
}
if ($tenant->event_credits_balance < 1) {
return response()->json([
'error' => 'Insufficient event credits. Please purchase more credits.',
], 402);
}
$validated = $request->validated();
$tenantId = $tenant->id;
$event = Event::create(array_merge($validated, [
$eventData = array_merge($validated, [
'tenant_id' => $tenantId,
'status' => 'draft', // Default status
'status' => $validated['status'] ?? 'draft',
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
]));
]);
// Decrement credits
$tenant->decrement('event_credits_balance', 1);
if (isset($eventData['event_date'])) {
$eventData['date'] = $eventData['event_date'];
unset($eventData['event_date']);
}
$settings = $eventData['settings'] ?? [];
foreach (['public_url', 'custom_domain', 'theme_color'] as $key) {
if (array_key_exists($key, $eventData)) {
$settings[$key] = $eventData[$key];
unset($eventData[$key]);
}
}
if (isset($eventData['features'])) {
$settings['features'] = $eventData['features'];
unset($eventData['features']);
}
if ($settings === [] || $settings === null) {
unset($eventData['settings']);
} else {
$eventData['settings'] = $settings;
}
foreach (['password', 'password_confirmation', 'password_protected', 'logo_image', 'cover_image'] as $unused) {
unset($eventData[$unused]);
}
$allowed = [
'tenant_id',
'name',
'description',
'date',
'slug',
'location',
'max_participants',
'settings',
'event_type_id',
'is_active',
'join_link_enabled',
'photo_upload_enabled',
'task_checklist_enabled',
'default_locale',
'status',
];
$eventData = Arr::only($eventData, $allowed);
$event = DB::transaction(function () use ($tenant, $eventData) {
$event = Event::create($eventData);
$note = sprintf('Event create: %s', $event->slug);
if (! $tenant->decrementCredits(1, 'event_create', $note, null)) {
throw new \RuntimeException('Unable to deduct credits');
}
return $event;
});
$tenant->refresh();
$event->load(['eventType', 'tenant']);
return response()->json([
'message' => 'Event created successfully',
'data' => new EventResource($event),
'balance' => $tenant->event_credits_balance,
], 201);
}
/**
* Display the specified event.
*/
public function show(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
@@ -99,7 +148,7 @@ class EventController extends Controller
'eventType',
'photos' => fn ($query) => $query->with('likes')->latest(),
'tasks',
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance')
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
]);
return response()->json([
@@ -107,27 +156,30 @@ class EventController extends Controller
]);
}
/**
* Update the specified event in storage.
*/
public function update(EventStoreRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
$validated = $request->validated();
// Update slug if name changed
if (isset($validated['event_date'])) {
$validated['date'] = $validated['event_date'];
unset($validated['event_date']);
}
if ($validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id);
}
$event->update($validated);
foreach (['password', 'password_confirmation', 'password_protected'] as $unused) {
unset($validated[$unused]);
}
$event->update($validated);
$event->load(['eventType', 'tenant']);
return response()->json([
@@ -136,19 +188,14 @@ class EventController extends Controller
]);
}
/**
* Remove the specified event from storage.
*/
public function destroy(Request $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404);
}
// Soft delete
$event->delete();
return response()->json([
@@ -156,9 +203,6 @@ class EventController extends Controller
]);
}
/**
* Bulk update event status (publish/unpublish)
*/
public function bulkUpdateStatus(Request $request): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
@@ -178,18 +222,15 @@ class EventController extends Controller
]);
}
/**
* Generate unique slug for event name
*/
private function generateUniqueSlug(string $name, int $tenantId, ?int $excludeId = null): string
{
$slug = Str::slug($name);
$originalSlug = $slug;
$counter = 1;
while (Event::where('slug', $slug)
->where('tenant_id', $tenantId)
->where('id', '!=', $excludeId)
->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId))
->exists()) {
$slug = $originalSlug . '-' . $counter;
$counter++;
@@ -198,9 +239,6 @@ class EventController extends Controller
return $slug;
}
/**
* Search events by name or description
*/
public function search(Request $request): AnonymousResourceCollection
{
$tenantId = $request->attributes->get('tenant_id');
@@ -213,7 +251,7 @@ class EventController extends Controller
$events = Event::where('tenant_id', $tenantId)
->where(function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%");
->orWhere('description', 'like', "%{$query}%");
})
->with('eventType')
->limit(10)
@@ -221,4 +259,4 @@ class EventController extends Controller
return EventResource::collection($events);
}
}
}

View File

@@ -140,7 +140,7 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null,
'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId,
]);
@@ -180,7 +180,7 @@ class OAuthController extends Controller
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
Log::warning('[OAuth] Authorization code missing or expired', [
'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null,
'refresh_token_id' => $storedRefreshToken->id,
]);
return $this->errorResponse('Invalid or expired authorization code', 400);
@@ -191,7 +191,7 @@ class OAuthController extends Controller
if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
Log::warning('[OAuth] Authorization code validation failed', [
'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null,
'refresh_token_id' => $storedRefreshToken->id,
]);
return $this->errorResponse('Invalid authorization code', 400);
@@ -221,7 +221,7 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null,
'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId,
]);
@@ -283,6 +283,15 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid refresh token', 400);
}
$storedIp = (string) ($storedRefreshToken->ip_address ?? '');
$currentIp = (string) ($request->ip() ?? '');
if ($storedIp !== '' && $currentIp !== '' && ! hash_equals($storedIp, $currentIp)) {
$storedRefreshToken->update(['revoked_at' => now()]);
return $this->errorResponse('Refresh token cannot be used from this IP address', 403);
}
$client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
if (! $client) {
return $this->errorResponse('Invalid client', 401);
@@ -292,7 +301,7 @@ class OAuthController extends Controller
if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null,
'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $storedRefreshToken->tenant_id,
]);
@@ -560,4 +569,4 @@ class OAuthController extends Controller
return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers;
use App\Jobs\ProcessRevenueCatWebhook;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class RevenueCatWebhookController extends Controller
{
public function handle(Request $request): JsonResponse
{
$secret = (string) config('services.revenuecat.webhook', '');
if ($secret === '') {
Log::error('RevenueCat webhook secret not configured');
return response()->json(['error' => 'Webhook not configured'], 500);
}
$signature = trim((string) $request->header('X-Signature', ''));
if ($signature === '') {
return response()->json(['error' => 'Signature missing'], 400);
}
$payload = $request->getContent();
if (! $this->signatureMatches($payload, $signature, $secret)) {
return response()->json(['error' => 'Invalid signature'], 400);
}
$decoded = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE || ! is_array($decoded)) {
Log::warning('RevenueCat webhook received invalid JSON', [
'error' => json_last_error_msg(),
]);
return response()->json(['error' => 'Invalid payload'], 400);
}
ProcessRevenueCatWebhook::dispatch(
$decoded,
(string) $request->header('X-Event-Id', '')
);
return response()->json(['status' => 'accepted'], 202);
}
private function signatureMatches(string $payload, string $providedSignature, string $secret): bool
{
$normalized = preg_replace('/\s+/', '', $providedSignature);
if (! is_string($normalized)) {
return false;
}
$candidates = [
base64_encode(hash_hmac('sha1', $payload, $secret, true)),
base64_encode(hash_hmac('sha256', $payload, $secret, true)),
hash_hmac('sha1', $payload, $secret),
hash_hmac('sha256', $payload, $secret),
];
foreach ($candidates as $expected) {
if (hash_equals($expected, $normalized)) {
return true;
}
}
return false;
}
}

View File

@@ -2,7 +2,6 @@
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
@@ -12,25 +11,32 @@ class CreditCheckMiddleware
{
public function handle(Request $request, Closure $next): Response
{
if ($request->isMethod('post') && $request->routeIs('api.v1.tenant.events.store')) {
$tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
$tenant = $this->resolveTenant($request);
$request->attributes->set('tenant', $tenant);
$request->attributes->set('tenant_id', $tenant->id);
$request->merge([
'tenant' => $tenant,
'tenant_id' => $tenant->id,
]);
}
if ($tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422);
}
$request->merge(['tenant' => $tenant]);
} elseif (($request->isMethod('put') || $request->isMethod('patch')) && $request->routeIs('api.v1.tenant.events.update')) {
$eventSlug = $request->route('event');
$event = Event::where('slug', $eventSlug)->firstOrFail();
$tenant = $event->tenant;
$request->merge(['tenant' => $tenant]);
if ($this->requiresCredits($request) && $tenant->event_credits_balance < 1) {
return response()->json([
'error' => 'Insufficient event credits. Please purchase more credits.',
], 402);
}
return $next($request);
}
private function requiresCredits(Request $request): bool
{
return $request->isMethod('post')
&& $request->routeIs('api.v1.tenant.events.store');
}
private function resolveTenant(Request $request): Tenant
{
$user = $request->user();

View File

@@ -2,55 +2,39 @@
namespace App\Http\Resources\Tenant;
use App\Http\Resources\Tenant\EventTypeResource;
use App\Http\Resources\Tenant\PhotoResource;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class EventResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$tenantId = $request->attributes->get('tenant_id');
// Hide sensitive data for other tenants
$showSensitive = $this->tenant_id === $tenantId;
$settings = is_array($this->settings) ? $this->settings : [];
return [
'id' => $this->id,
'name' => $this->name,
'slug' => $this->slug,
'description' => $this->description,
'event_date' => $this->event_date ? $this->event_date->toISOString() : null,
'event_date' => $this->date ? $this->date->toISOString() : null,
'location' => $this->location,
'max_participants' => $this->max_participants,
'current_participants' => $showSensitive ? $this->photos_count : null,
'public_url' => $this->public_url,
'custom_domain' => $showSensitive ? $this->custom_domain : null,
'theme_color' => $this->theme_color,
'status' => $showSensitive ? $this->status : 'published',
'password_protected' => $this->password_protected,
'features' => $this->features,
'event_type' => new EventTypeResource($this->whenLoaded('eventType')),
'photos' => PhotoResource::collection($this->whenLoaded('photos')),
'tasks' => $showSensitive ? $this->whenLoaded('tasks') : [],
'tenant' => $showSensitive ? [
'id' => $this->tenant->id,
'name' => $this->tenant->name,
'event_credits_balance' => $this->tenant->event_credits_balance,
] : null,
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
'photo_count' => $this->photos_count,
'like_count' => $this->photos->sum('likes_count'),
'is_public' => $this->status === 'published' && !$this->password_protected,
'public_share_url' => $showSensitive ? route('api.v1.events.show', ['slug' => $this->slug]) : null,
'qr_code_url' => $showSensitive ? route('api.v1.events.qr', ['event' => $this->id]) : null,
'current_participants' => $showSensitive ? ($this->photos_count ?? null) : null,
'public_url' => $settings['public_url'] ?? null,
'custom_domain' => $showSensitive ? ($settings['custom_domain'] ?? null) : null,
'theme_color' => $settings['theme_color'] ?? null,
'status' => $this->status ?? 'draft',
'features' => $settings['features'] ?? [],
'event_type_id' => $this->event_type_id,
'created_at' => $this->created_at?->toISOString(),
'updated_at' => $this->updated_at?->toISOString(),
'photo_count' => $this->photos_count ?? 0,
'like_count' => $this->whenLoaded('photos', fn () => $this->photos->sum('likes_count'), 0),
'is_public' => $this->status === 'published',
'public_share_url' => null,
'qr_code_url' => null,
];
}
}
}

View 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;
}
}

View File

@@ -25,6 +25,11 @@ class Event extends Model
return $this->belongsTo(Tenant::class);
}
public function eventType(): BelongsTo
{
return $this->belongsTo(EventType::class);
}
public function photos(): HasMany
{
return $this->hasMany(Photo::class);
@@ -46,4 +51,3 @@ class Event extends Model
->withTimestamps();
}
}

View File

@@ -78,11 +78,20 @@ class Tenant extends Model
public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool
{
if ($this->event_credits_balance < $amount) {
return false;
if ($amount <= 0) {
return true;
}
return DB::transaction(function () use ($amount, $reason, $note, $relatedPurchaseId) {
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
$locked = static::query()
->whereKey($this->getKey())
->lockForUpdate()
->first();
if (! $locked || $locked->event_credits_balance < $amount) {
return false;
}
EventCreditsLedger::create([
'tenant_id' => $this->id,
'delta' => -$amount,
@@ -91,13 +100,33 @@ class Tenant extends Model
'note' => $note,
]);
return $this->decrement('event_credits_balance', $amount);
});
$locked->event_credits_balance -= $amount;
$locked->save();
$this->event_credits_balance = $locked->event_credits_balance;
return true;
};
return $this->runCreditOperation($operation);
}
public function incrementCredits(int $amount, string $reason = 'manual_adjust', ?string $note = null, ?int $relatedPurchaseId = null): bool
{
return DB::transaction(function () use ($amount, $reason, $note, $relatedPurchaseId) {
if ($amount <= 0) {
return true;
}
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
$locked = static::query()
->whereKey($this->getKey())
->lockForUpdate()
->first();
if (! $locked) {
return false;
}
EventCreditsLedger::create([
'tenant_id' => $this->id,
'delta' => $amount,
@@ -106,7 +135,25 @@ class Tenant extends Model
'note' => $note,
]);
return $this->increment('event_credits_balance', $amount);
});
$locked->event_credits_balance += $amount;
$locked->save();
$this->event_credits_balance = $locked->event_credits_balance;
return true;
};
return $this->runCreditOperation($operation);
}
private function runCreditOperation(callable $operation): bool
{
$connection = DB::connection();
if ($connection->transactionLevel() > 0) {
return (bool) $operation();
}
return (bool) $connection->transaction($operation);
}
}

View File

@@ -2,6 +2,9 @@
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
RateLimiter::for('tenant-api', function (Request $request) {
$tenantId = $request->attributes->get('tenant_id')
?? $request->user()?->tenant_id
?? $request->user()?->tenant?->id;
$key = $tenantId ? 'tenant:' . $tenantId : ('ip:' . ($request->ip() ?? 'unknown'));
return Limit::perMinute(100)->by($key);
});
RateLimiter::for('oauth', function (Request $request) {
return Limit::perMinute(10)->by('oauth:' . ($request->ip() ?? 'unknown'));
});
}
}