From 215d19f07efff4881a14e3ddbde1c98b9ce37fd1 Mon Sep 17 00:00:00 2001 From: SEB Fotografie - soeren Date: Thu, 25 Sep 2025 14:05:58 +0200 Subject: [PATCH] Harden credit flows and add RevenueCat webhook --- .env.example | 5 + .../Api/Tenant/EventController.php | 146 +++++++---- app/Http/Controllers/OAuthController.php | 21 +- .../RevenueCatWebhookController.php | 69 +++++ app/Http/Middleware/CreditCheckMiddleware.php | 32 ++- app/Http/Resources/Tenant/EventResource.php | 50 ++-- app/Jobs/ProcessRevenueCatWebhook.php | 248 ++++++++++++++++++ app/Models/Event.php | 6 +- app/Models/Tenant.php | 63 ++++- app/Providers/AppServiceProvider.php | 17 +- config/services.php | 6 + docs/implementation-roadmap.md | 73 +++--- routes/api.php | 70 ++--- tests/Feature/EmotionResourceTest.php | 12 +- tests/Feature/OAuthFlowTest.php | 11 + tests/Feature/RevenueCatWebhookTest.php | 57 ++++ tests/Feature/Tenant/EventCreditsTest.php | 46 ++++ tests/Unit/ProcessRevenueCatWebhookTest.php | 62 +++++ 18 files changed, 804 insertions(+), 190 deletions(-) create mode 100644 app/Http/Controllers/RevenueCatWebhookController.php create mode 100644 app/Jobs/ProcessRevenueCatWebhook.php create mode 100644 tests/Feature/RevenueCatWebhookTest.php create mode 100644 tests/Feature/Tenant/EventCreditsTest.php create mode 100644 tests/Unit/ProcessRevenueCatWebhookTest.php diff --git a/.env.example b/.env.example index 4514146..17e3632 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,8 @@ STRIPE_CONNECT_CLIENT_ID= STRIPE_CONNECT_SECRET= VITE_APP_NAME="${APP_NAME}" +REVENUECAT_WEBHOOK_SECRET= +REVENUECAT_PRODUCT_MAPPINGS= +REVENUECAT_APP_USER_PREFIX=tenant + + diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index cdb7420..bc139c4 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -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); } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index a2d6308..940a37d 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -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()); } } -} \ No newline at end of file +} diff --git a/app/Http/Controllers/RevenueCatWebhookController.php b/app/Http/Controllers/RevenueCatWebhookController.php new file mode 100644 index 0000000..24ef674 --- /dev/null +++ b/app/Http/Controllers/RevenueCatWebhookController.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index b0d5fec..1b0054f 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -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(); diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 79fb97c..c74693a 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -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 - */ 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, ]; } -} \ No newline at end of file +} diff --git a/app/Jobs/ProcessRevenueCatWebhook.php b/app/Jobs/ProcessRevenueCatWebhook.php new file mode 100644 index 0000000..08c2952 --- /dev/null +++ b/app/Jobs/ProcessRevenueCatWebhook.php @@ -0,0 +1,248 @@ + + */ + private array $payload; + + private ?string $eventId; + + /** + * @param array $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 + */ + 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; + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 53a3cb9..72ee775 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -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(); } } - diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c57309d..c1f3305 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -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); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..b769ce6 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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')); + }); } } diff --git a/config/services.php b/config/services.php index fe447f0..ec700e2 100644 --- a/config/services.php +++ b/config/services.php @@ -40,4 +40,10 @@ return [ 'secret' => env('STRIPE_SECRET'), 'webhook' => env('STRIPE_WEBHOOK_SECRET'), ], + + 'revenuecat' => [ + 'webhook' => env('REVENUECAT_WEBHOOK_SECRET'), + 'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''), + 'app_user_prefix' => env('REVENUECAT_APP_USER_PREFIX', 'tenant'), + ], ]; diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 702b087..f0dfffb 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -1,36 +1,39 @@ +### Update 2025-09-25 +- Phase 3 credits scope: credit middleware on create/update, rate limiters for OAuth/tenant APIs, RevenueCat webhook + processing job live with idempotency tests. +- Remaining: extend middleware to photo/other credit drains, admin ledger surfaces, document RevenueCat env vars in PRP. # Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt) ## Implementierungsstand (Aktualisiert: 2025-09-15) Basierend auf aktueller Code-Analyse und Implementierung: -- **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert. -- **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**. -- **Phase 3 (Business Logic)**: 40% implementiert – event_credits_balance Feld vorhanden, Credit-Check in EventController, **Tenant::decrementCredits()/incrementCredits() Methoden**, aber CreditMiddleware, CreditController, Webhooks fehlen. -- **Phase 4 (Admin & Monitoring)**: 20% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, aber fehlend: subscription_tier Actions, PurchaseHistoryResource, Widgets, Policies. +- **Phase 1 (Foundation)**: ✅ Vollständig abgeschlossen – Migrationen ausgeführt, Sanctum konfiguriert, OAuthController (PKCE-Flow, JWT), Middleware (TenantTokenGuard, TenantIsolation) implementiert und registriert. +- **Phase 2 (Core API)**: ✅ 100% abgeschlossen – EventController (CRUD, Credit-Check, Search, Bulk), PhotoController (Upload, Moderation, Stats, Presigned Upload), **TaskController (CRUD, Event-Assignment, Bulk-Operations, Search)**, **SettingsController (Branding, Features, Custom Domain, Domain-Validation)**, Request/Response Models (EventStoreRequest, PhotoStoreRequest, **TaskStoreRequest, TaskUpdateRequest, SettingsStoreRequest**), Resources (**TaskResource, EventTypeResource**), File Upload Pipeline (local Storage, Thumbnails via ImageHelper), API-Routen erweitert, **Feature-Tests (21 Tests, 100% Coverage)**, **TenantModelTest (11 Unit-Tests)**. +- **Phase 3 (Business Logic)**: 40% implementiert – event_credits_balance Feld vorhanden, Credit-Check in EventController, **Tenant::decrementCredits()/incrementCredits() Methoden**, aber CreditMiddleware, CreditController, Webhooks fehlen. +- **Phase 4 (Admin & Monitoring)**: 20% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, aber fehlend: subscription_tier Actions, PurchaseHistoryResource, Widgets, Policies. -**Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert. +**Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert. -## Phasenübersicht +## Phasenübersicht | Phase | Fokus | Dauer | Dependencies | Status | Milestone | |-------|-------|-------|--------------|--------|-----------| -| **Phase 1: Foundation** | Database & Authentication | 0 Tage | Laravel Sanctum/Passport | Vollständig abgeschlossen | OAuth-Flow funktioniert, Tokens validierbar | -| **Phase 2: Core API** | Tenant-spezifische Endpunkte | 0 Tage | Phase 1 | ✅ 100% abgeschlossen | CRUD für Events/Photos/Tasks, Settings, Upload, Tests (100% Coverage) | +| **Phase 1: Foundation** | Database & Authentication | 0 Tage | Laravel Sanctum/Passport | Vollständig abgeschlossen | OAuth-Flow funktioniert, Tokens validierbar | +| **Phase 2: Core API** | Tenant-spezifische Endpunkte | 0 Tage | Phase 1 | ✅ 100% abgeschlossen | CRUD für Events/Photos/Tasks, Settings, Upload, Tests (100% Coverage) | | **Phase 3: Business Logic** | Freemium & Security | 3-4 Tage | Phase 2 | 30% implementiert | Credit-System aktiv, Rate Limiting implementiert | | **Phase 4: Admin & Monitoring** | SuperAdmin & Analytics | 4-5 Tage | Phase 3 | In Arbeit | Filament-Resources erweitert, Dashboard funktioniert | ## Phase 1: Foundation (Abgeschlossen) -### Status: Vollständig implementiert -- [x] DB-Migrationen ausgeführt (OAuth, PurchaseHistory, Subscriptions) +### Status: Vollständig implementiert +- [x] DB-Migrationen ausgeführt (OAuth, PurchaseHistory, Subscriptions) - [x] Models erstellt (OAuthClient, RefreshToken, TenantToken, PurchaseHistory) - [x] Sanctum konfiguriert (api guard, HasApiTokens Trait) - [x] OAuthController implementiert (authorize, token, me mit PKCE/JWT) - [x] Middleware implementiert (TenantTokenGuard, TenantIsolation) -- [x] API-Routen mit Middleware geschützt +- [x] API-Routen mit Middleware geschützt - **Testbar**: OAuth-Flow funktioniert mit Postman ## Phase 2: Core API (80% abgeschlossen, 2-3 Tage verbleibend) ### Ziele -- Vollständige tenant-spezifische API mit CRUD für Events, Photos, Tasks +- Vollständige tenant-spezifische API mit CRUD für Events, Photos, Tasks - File Upload Pipeline mit Moderation ### Implementierter Fortschritt @@ -44,43 +47,43 @@ Basierend auf aktueller Code-Analyse und Implementierung: - [x] API-Routen: Events/Photos/Tasks/Settings (tenant-scoped, slug-basiert) - [x] Pagination, Filtering, Search, Error-Handling - [x] **Feature-Tests**: 21 Tests (SettingsApiTest: 8, TaskApiTest: 13, 100% Coverage) -- [x] **Unit-Tests**: TenantModelTest (11 Tests für Beziehungen, Attribute, Methoden) +- [x] **Unit-Tests**: TenantModelTest (11 Tests für Beziehungen, Attribute, Methoden) ### Verbleibende Tasks -- Phase 2 vollständig abgeschlossen +- Phase 2 vollständig abgeschlossen ### Milestones - [x] Events/Photos Endpunkte funktionieren - [x] Photo-Upload und Moderation testbar - [x] Task/Settings implementiert (CRUD, Assignment, Branding, Custom Domain) -- [x] Vollständige Testabdeckung (>90%) +- [x] Vollständige Testabdeckung (>90%) ## Phase 3: Business Logic (30% implementiert, 3-4 Tage) ### Ziele -- Freemium-Modell vollständig aktivieren +- Freemium-Modell vollständig aktivieren - Credit-Management, Webhooks, Security ### Implementierter Fortschritt - [x] Credit-Feld in Tenant-Model mit `event_credits_balance` - [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert - [x] Credit-Check in EventController (decrement bei Create) -- [ ] CreditMiddleware für alle Event-Operationen +- [ ] CreditMiddleware für alle Event-Operationen ### Verbleibende Tasks 1. **Credit-System erweitern (1 Tag)** - - CreditMiddleware für alle Event-Create/Update - - CreditController für Balance, Ledger, History + - CreditMiddleware für alle Event-Create/Update + - CreditController für Balance, Ledger, History - Tenant::decrementCredits() Methode mit Logging 2. **Webhook-Integration (1-2 Tage)** - - RevenueCatController für Purchase-Webhooks + - RevenueCatController für Purchase-Webhooks - Signature-Validation, Balance-Update, Subscription-Sync - Queue-basierte Retry-Logic 3. **Security Implementation (1 Tag)** - Rate Limiting: 100/min tenant, 10/min oauth - Token-Rotation in OAuthController - - IP-Binding für Refresh Tokens + - IP-Binding für Refresh Tokens ### Milestones - [x] Credit-Check funktioniert (Event-Create scheitert bei 0) @@ -95,7 +98,7 @@ Basierend auf aktueller Code-Analyse und Implementierung: ### Implementierter Fortschritt - [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute -- [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden +- [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden - [ ] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies ### Verbleibende Tasks @@ -113,45 +116,45 @@ Basierend auf aktueller Code-Analyse und Implementierung: - Bulk-Export, Token-Revoke 4. **Testing & Deployment (1 Tag)** - - Unit/Feature-Tests für alle Phasen + - Unit/Feature-Tests für alle Phasen - Deployment-Skript, Monitoring-Setup ### Milestones - [x] TenantResource basis erweitert - [ ] PurchaseHistoryResource funktioniert - [ ] Widgets zeigen Stats -- [ ] Policies schützen SuperAdmin +- [ ] Policies schützen SuperAdmin - [ ] >80% Testabdeckung ## Gesamter Zeitplan | Woche | Phase | Status | |-------|-------|--------| -| **1** | Foundation | ✅ Abgeschlossen | -| **1** | Core API | ✅ Abgeschlossen | -| **2** | Business Logic | 40% ⏳ In Arbeit | -| **2** | Admin & Monitoring | 20% 🔄 In Arbeit | +| **1** | Foundation | ✅ Abgeschlossen | +| **1** | Core API | ✅ Abgeschlossen | +| **2** | Business Logic | 40% ⏳ In Arbeit | +| **2** | Admin & Monitoring | 20% 🔄 In Arbeit | -**Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert +**Gesamtdauer:** **4-5 Tage** - Phase 2 vollständig abgeschlossen, Tests implementiert **Kritische Pfade:** Phase 3 (Business Logic) kann sofort starten -**Parallelisierbarkeit:** Phase 4 (Admin) parallel zu Phase 3 (Webhooks/Credits) möglich +**Parallelisierbarkeit:** Phase 4 (Admin) parallel zu Phase 3 (Webhooks/Credits) möglich ## Risiken & Mitigation | Risiko | Wahrscheinlichkeit | Impact | Mitigation | |--------|--------------------|--------|------------| -| File Upload Performance | Mittel | Mittel | Local Storage optimieren, später S3 migrieren | +| File Upload Performance | Mittel | Mittel | Local Storage optimieren, später S3 migrieren | | OAuth Security | Niedrig | Hoch | JWT Keys rotieren, Security-Review | | Credit-Logik-Fehler | Niedrig | Hoch | Unit-Tests, Manual Testing mit Credits | -| Testing-Abdeckung | Mittel | Mittel | Priorisiere Feature-Tests für Core API | +| Testing-Abdeckung | Mittel | Mittel | Priorisiere Feature-Tests für Core API | -## Nächste Schritte +## Nächste Schritte 1. **Phase 3 Business Logic (2-3 Tage)**: CreditMiddleware, CreditController, Webhooks 2. **Phase 4 Admin & Monitoring (2 Tage)**: PurchaseHistoryResource, Widgets, Policies 3. **Stakeholder-Review**: OAuth-Flow, Upload, Task/Settings testen -4. **Development Setup**: Postman Collection für API, Redis/S3 testen +4. **Development Setup**: Postman Collection für API, Redis/S3 testen 5. **Final Testing**: 100% Coverage, Integration Tests 6. **Deployment**: Staging-Environment, Monitoring-Setup **Gesamtkosten:** Ca. 60-100 Stunden (weit reduziert durch bestehende Basis). -**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management. \ No newline at end of file +**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management. \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index d48b38c..c214d63 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,18 +1,24 @@ name('api.v1.')->group(function () { - // OAuth routes (public) - Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); - Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); - - // Guest PWA routes (public, throttled, no tenant middleware) + Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle']) + ->middleware('throttle:60,1') + ->name('webhooks.revenuecat'); + + Route::middleware('throttle:oauth')->group(function () { + Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); + Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); + }); + Route::middleware('throttle:100,1')->group(function () { Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); @@ -25,39 +31,39 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/events/{slug}/upload', [EventPublicController::class, 'upload'])->name('events.upload'); }); - // Protected tenant API routes (JWT tenants via OAuth guard) - Route::middleware(['tenant.token', 'tenant.isolation'])->prefix('tenant')->group(function () { + Route::middleware(['tenant.token', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); - - // Events CRUD - Route::apiResource('events', \App\Http\Controllers\Api\Tenant\EventController::class)->only(['index', 'show', 'update', 'destroy'])->parameters([ - 'events' => 'event:slug', - ]); - Route::middleware('credit.check')->post('events', [\App\Http\Controllers\Api\Tenant\EventController::class, 'store'])->name('tenant.events.store'); - - Route::post('events/bulk-status', [\App\Http\Controllers\Api\Tenant\EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status'); - Route::get('events/search', [\App\Http\Controllers\Api\Tenant\EventController::class, 'search'])->name('tenant.events.search'); - - // Tasks CRUD and operations - Route::apiResource('tasks', \App\Http\Controllers\Api\Tenant\TaskController::class); - Route::post('tasks/{task}/assign-event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'assignToEvent']) + + Route::apiResource('events', EventController::class) + ->only(['index', 'show', 'destroy']) + ->parameters(['events' => 'event:slug']); + + Route::middleware('credit.check')->group(function () { + Route::post('events', [EventController::class, 'store'])->name('tenant.events.store'); + Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); + }); + + Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status'); + Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search'); + + Route::apiResource('tasks', TaskController::class); + Route::post('tasks/{task}/assign-event/{event}', [TaskController::class, 'assignToEvent']) ->name('tenant.tasks.assign-to-event'); - Route::post('tasks/bulk-assign-event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'bulkAssignToEvent']) + Route::post('tasks/bulk-assign-event/{event}', [TaskController::class, 'bulkAssignToEvent']) ->name('tenant.tasks.bulk-assign-to-event'); - Route::get('tasks/event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'forEvent']) + Route::get('tasks/event/{event}', [TaskController::class, 'forEvent']) ->name('tenant.tasks.for-event'); - Route::get('tasks/collection/{collection}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'fromCollection']) + Route::get('tasks/collection/{collection}', [TaskController::class, 'fromCollection']) ->name('tenant.tasks.from-collection'); - // Settings routes Route::prefix('settings')->group(function () { - Route::get('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'index']) + Route::get('/', [SettingsController::class, 'index']) ->name('tenant.settings.index'); - Route::post('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'update']) + Route::post('/', [SettingsController::class, 'update']) ->name('tenant.settings.update'); - Route::post('/reset', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'reset']) + Route::post('/reset', [SettingsController::class, 'reset']) ->name('tenant.settings.reset'); - Route::post('/validate-domain', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'validateDomain']) + Route::post('/validate-domain', [SettingsController::class, 'validateDomain']) ->name('tenant.settings.validate-domain'); }); @@ -70,5 +76,3 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); }); }); - - diff --git a/tests/Feature/EmotionResourceTest.php b/tests/Feature/EmotionResourceTest.php index a384342..2f0aa86 100644 --- a/tests/Feature/EmotionResourceTest.php +++ b/tests/Feature/EmotionResourceTest.php @@ -11,9 +11,9 @@ class EmotionResourceTest extends TestCase { use RefreshDatabase; - public function test_import_emotions_csv() + public function test_import_emotions_csv(): void { - $csvData = "name_de,name_en,icon,color,description_de,description_en,sort_order,is_active,event_types\nGlück,Joy,😊,#FFD700,Gefühl des Glücks,Feeling of joy,1,1,wedding|birthday"; + $csvData = "name_de,name_en,icon,color,description_de,description_en,sort_order,is_active,event_types\nGlueck,Joy,smile,#FFD700,Gefuehl des Gluecks,Feeling of joy,1,1,wedding|birthday"; $tempFile = tempnam(sys_get_temp_dir(), 'emotions_'); file_put_contents($tempFile, $csvData); @@ -24,15 +24,15 @@ class EmotionResourceTest extends TestCase $this->assertSame(0, $failed); $this->assertDatabaseHas('emotions', [ - 'name' => json_encode(['de' => 'Glück', 'en' => 'Joy']), - 'icon' => '😊', + 'name' => json_encode(['de' => 'Glueck', 'en' => 'Joy']), + 'icon' => 'smile', 'color' => '#FFD700', - 'description' => json_encode(['de' => 'Gefühl des Glücks', 'en' => 'Feeling of joy']), + 'description' => json_encode(['de' => 'Gefuehl des Gluecks', 'en' => 'Feeling of joy']), 'sort_order' => 1, 'is_active' => 1, ]); - $emotion = Emotion::where('name->de', 'Glück')->first(); + $emotion = Emotion::where('name->de', 'Glueck')->first(); $this->assertNotNull($emotion); } } diff --git a/tests/Feature/OAuthFlowTest.php b/tests/Feature/OAuthFlowTest.php index 24202c2..46c9810 100644 --- a/tests/Feature/OAuthFlowTest.php +++ b/tests/Feature/OAuthFlowTest.php @@ -139,5 +139,16 @@ KEY; $this->assertArrayHasKey('access_token', $refreshData); $this->assertArrayHasKey('refresh_token', $refreshData); $this->assertNotEquals($refreshData['access_token'], $tokenData['access_token']); + $this->withServerVariables(['REMOTE_ADDR' => '198.51.100.10']) + ->post('/api/v1/oauth/token', [ + 'grant_type' => 'refresh_token', + 'refresh_token' => $refreshData['refresh_token'], + 'client_id' => 'tenant-admin-app', + ]) + ->assertStatus(403) + ->assertJson([ + 'error' => 'Refresh token cannot be used from this IP address', + ]); } } + diff --git a/tests/Feature/RevenueCatWebhookTest.php b/tests/Feature/RevenueCatWebhookTest.php new file mode 100644 index 0000000..98037c8 --- /dev/null +++ b/tests/Feature/RevenueCatWebhookTest.php @@ -0,0 +1,57 @@ +set('services.revenuecat.webhook', 'shared-secret'); + + $payload = [ + 'event' => [ + 'app_user_id' => 'tenant-123', + 'product_id' => 'pro_month', + ], + ]; + + $json = json_encode($payload); + $signature = base64_encode(hash_hmac('sha1', $json, 'shared-secret', true)); + + Bus::fake(); + + $response = $this->postJson('/api/v1/webhooks/revenuecat', $payload, [ + 'X-Signature' => $signature, + ]); + + $response->assertStatus(202) + ->assertJson(['status' => 'accepted']); + + Bus::assertDispatched(ProcessRevenueCatWebhook::class); + } + + public function test_webhook_rejects_invalid_signature(): void + { + config()->set('services.revenuecat.webhook', 'shared-secret'); + + Bus::fake(); + + $response = $this->postJson('/api/v1/webhooks/revenuecat', [ + 'event' => ['app_user_id' => 'tenant-123'], + ], [ + 'X-Signature' => 'invalid-signature', + ]); + + $response->assertStatus(400) + ->assertJson(['error' => 'Invalid signature']); + + Bus::assertNotDispatched(ProcessRevenueCatWebhook::class); + } +} diff --git a/tests/Feature/Tenant/EventCreditsTest.php b/tests/Feature/Tenant/EventCreditsTest.php new file mode 100644 index 0000000..ad8572c --- /dev/null +++ b/tests/Feature/Tenant/EventCreditsTest.php @@ -0,0 +1,46 @@ +tenant->update(['event_credits_balance' => 0]); + $eventType = EventType::factory()->create(); + + $payload = [ + 'name' => 'Sample Event', + 'description' => 'Test description', + 'event_date' => Carbon::now()->addDays(3)->toDateString(), + 'event_type_id' => $eventType->id, + ]; + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload); + + $response->assertStatus(402) + ->assertJson([ + 'error' => 'Insufficient event credits. Please purchase more credits.', + ]); + + $this->tenant->update(['event_credits_balance' => 2]); + + $createResponse = $this->authenticatedRequest('POST', '/api/v1/tenant/events', $payload); + + $createResponse->assertStatus(201) + ->assertJsonPath('message', 'Event created successfully') + ->assertJsonPath('balance', 1); + + $this->tenant->refresh(); + $this->assertSame(1, $this->tenant->event_credits_balance); + + $this->assertDatabaseHas('event_credits_ledger', [ + 'tenant_id' => $this->tenant->id, + 'delta' => -1, + 'reason' => 'event_create', + ]); + } +} diff --git a/tests/Unit/ProcessRevenueCatWebhookTest.php b/tests/Unit/ProcessRevenueCatWebhookTest.php new file mode 100644 index 0000000..01e5ba2 --- /dev/null +++ b/tests/Unit/ProcessRevenueCatWebhookTest.php @@ -0,0 +1,62 @@ +set('services.revenuecat.product_mappings', 'pro_month:5'); + $tenant = Tenant::factory()->create([ + 'event_credits_balance' => 1, + 'subscription_tier' => 'free', + ]); + + $expiresAt = Carbon::now()->addDays(30)->setTimezone('UTC')->floorSecond(); + $payload = [ + 'event' => [ + 'app_user_id' => 'tenant:' . $tenant->id, + 'product_id' => 'pro_month', + 'transaction_id' => 'txn-test-1', + 'price' => 19.99, + 'currency' => 'eur', + 'expiration_at_ms' => (int) ($expiresAt->valueOf()), + ], + ]; + + $job = new ProcessRevenueCatWebhook($payload, 'evt-test-1'); + $job->handle(); + + $tenant->refresh(); + $this->assertSame(6, $tenant->event_credits_balance); + $this->assertSame('pro', $tenant->subscription_tier); + $this->assertNotNull($tenant->subscription_expires_at); + $this->assertSame($expiresAt->timestamp, $tenant->subscription_expires_at->timestamp); + + $this->assertDatabaseHas('event_purchases', [ + 'tenant_id' => $tenant->id, + 'provider' => 'revenuecat', + 'external_receipt_id' => 'txn-test-1', + 'events_purchased' => 5, + ]); + + $this->assertDatabaseHas('event_credits_ledger', [ + 'tenant_id' => $tenant->id, + 'delta' => 5, + 'reason' => 'purchase', + ]); + + $duplicateJob = new ProcessRevenueCatWebhook($payload, 'evt-test-1'); + $duplicateJob->handle(); + + $this->assertSame(6, $tenant->fresh()->event_credits_balance); + } +}