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

@@ -70,3 +70,8 @@ STRIPE_CONNECT_CLIENT_ID=
STRIPE_CONNECT_SECRET= STRIPE_CONNECT_SECRET=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
REVENUECAT_WEBHOOK_SECRET=
REVENUECAT_PRODUCT_MAPPINGS=
REVENUECAT_APP_USER_PREFIX=tenant

View File

@@ -4,26 +4,24 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Tenant\EventStoreRequest; use App\Http\Requests\Tenant\EventStoreRequest;
use Illuminate\Support\Str;
use App\Http\Resources\Tenant\EventResource; use App\Http\Resources\Tenant\EventResource;
use App\Models\Event; use App\Models\Event;
use App\Models\Tenant; use App\Models\Tenant;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\JsonResponse; use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class EventController extends Controller class EventController extends Controller
{ {
/**
* Display a listing of the tenant's events.
*/
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
if (!$tenantId) { if (! $tenantId) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'tenant_id' => 'Tenant ID not found in request context.', 'tenant_id' => 'Tenant ID not found in request context.',
]); ]);
@@ -33,7 +31,6 @@ class EventController extends Controller
->with(['eventType', 'photos']) ->with(['eventType', 'photos'])
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc');
// Apply filters
if ($request->has('status')) { if ($request->has('status')) {
$query->where('status', $request->status); $query->where('status', $request->status);
} }
@@ -42,55 +39,107 @@ class EventController extends Controller
$query->where('event_type_id', $request->type_id); $query->where('event_type_id', $request->type_id);
} }
// Pagination $events = $query->paginate($request->get('per_page', 15));
$perPage = $request->get('per_page', 15);
$events = $query->paginate($perPage);
return EventResource::collection($events); return EventResource::collection($events);
} }
/**
* Store a newly created event in storage.
*/
public function store(EventStoreRequest $request): JsonResponse public function store(EventStoreRequest $request): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) {
// Check credits balance $tenantId = $request->attributes->get('tenant_id');
$tenant = Tenant::findOrFail($tenantId); $tenant = Tenant::findOrFail($tenantId);
if ($tenant->event_credits_balance <= 0) { }
if ($tenant->event_credits_balance < 1) {
return response()->json([ return response()->json([
'error' => 'Insufficient event credits. Please purchase more credits.', 'error' => 'Insufficient event credits. Please purchase more credits.',
], 402); ], 402);
} }
$validated = $request->validated(); $validated = $request->validated();
$tenantId = $tenant->id;
$event = Event::create(array_merge($validated, [ $eventData = array_merge($validated, [
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
'status' => 'draft', // Default status 'status' => $validated['status'] ?? 'draft',
'slug' => $this->generateUniqueSlug($validated['name'], $tenantId), 'slug' => $this->generateUniqueSlug($validated['name'], $tenantId),
])); ]);
// Decrement credits if (isset($eventData['event_date'])) {
$tenant->decrement('event_credits_balance', 1); $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']); $event->load(['eventType', 'tenant']);
return response()->json([ return response()->json([
'message' => 'Event created successfully', 'message' => 'Event created successfully',
'data' => new EventResource($event), 'data' => new EventResource($event),
'balance' => $tenant->event_credits_balance,
], 201); ], 201);
} }
/**
* Display the specified event.
*/
public function show(Request $request, Event $event): JsonResponse public function show(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404); return response()->json(['error' => 'Event not found'], 404);
} }
@@ -99,7 +148,7 @@ class EventController extends Controller
'eventType', 'eventType',
'photos' => fn ($query) => $query->with('likes')->latest(), 'photos' => fn ($query) => $query->with('likes')->latest(),
'tasks', 'tasks',
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance') 'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
]); ]);
return response()->json([ 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 public function update(EventStoreRequest $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404); return response()->json(['error' => 'Event not found'], 404);
} }
$validated = $request->validated(); $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) { if ($validated['name'] !== $event->name) {
$validated['slug'] = $this->generateUniqueSlug($validated['name'], $tenantId, $event->id); $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']); $event->load(['eventType', 'tenant']);
return response()->json([ 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 public function destroy(Request $request, Event $event): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
// Ensure event belongs to tenant
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
return response()->json(['error' => 'Event not found'], 404); return response()->json(['error' => 'Event not found'], 404);
} }
// Soft delete
$event->delete(); $event->delete();
return response()->json([ return response()->json([
@@ -156,9 +203,6 @@ class EventController extends Controller
]); ]);
} }
/**
* Bulk update event status (publish/unpublish)
*/
public function bulkUpdateStatus(Request $request): JsonResponse public function bulkUpdateStatus(Request $request): JsonResponse
{ {
$tenantId = $request->attributes->get('tenant_id'); $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 private function generateUniqueSlug(string $name, int $tenantId, ?int $excludeId = null): string
{ {
$slug = Str::slug($name); $slug = Str::slug($name);
$originalSlug = $slug; $originalSlug = $slug;
$counter = 1; $counter = 1;
while (Event::where('slug', $slug) while (Event::where('slug', $slug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->where('id', '!=', $excludeId) ->when($excludeId, fn ($query) => $query->where('id', '!=', $excludeId))
->exists()) { ->exists()) {
$slug = $originalSlug . '-' . $counter; $slug = $originalSlug . '-' . $counter;
$counter++; $counter++;
@@ -198,9 +239,6 @@ class EventController extends Controller
return $slug; return $slug;
} }
/**
* Search events by name or description
*/
public function search(Request $request): AnonymousResourceCollection public function search(Request $request): AnonymousResourceCollection
{ {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
@@ -213,7 +251,7 @@ class EventController extends Controller
$events = Event::where('tenant_id', $tenantId) $events = Event::where('tenant_id', $tenantId)
->where(function ($q) use ($query) { ->where(function ($q) use ($query) {
$q->where('name', 'like', "%{$query}%") $q->where('name', 'like', "%{$query}%")
->orWhere('description', 'like', "%{$query}%"); ->orWhere('description', 'like', "%{$query}%");
}) })
->with('eventType') ->with('eventType')
->limit(10) ->limit(10)
@@ -221,4 +259,4 @@ class EventController extends Controller
return EventResource::collection($events); return EventResource::collection($events);
} }
} }

View File

@@ -140,7 +140,7 @@ class OAuthController extends Controller
if (! $tenant) { if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [ Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id, 'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null, 'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
]); ]);
@@ -180,7 +180,7 @@ class OAuthController extends Controller
if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) { if (! $cachedCode || Arr::get($cachedCode, 'expires_at') < now()) {
Log::warning('[OAuth] Authorization code missing or expired', [ Log::warning('[OAuth] Authorization code missing or expired', [
'client_id' => $request->client_id, 'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null, 'refresh_token_id' => $storedRefreshToken->id,
]); ]);
return $this->errorResponse('Invalid or expired authorization code', 400); 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)) { if (! $oauthCode || $oauthCode->isExpired() || ! Hash::check($request->code, $oauthCode->code)) {
Log::warning('[OAuth] Authorization code validation failed', [ Log::warning('[OAuth] Authorization code validation failed', [
'client_id' => $request->client_id, 'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null, 'refresh_token_id' => $storedRefreshToken->id,
]); ]);
return $this->errorResponse('Invalid authorization code', 400); return $this->errorResponse('Invalid authorization code', 400);
@@ -221,7 +221,7 @@ class OAuthController extends Controller
if (! $tenant) { if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [ Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id, 'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null, 'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $tenantId, 'tenant_id' => $tenantId,
]); ]);
@@ -283,6 +283,15 @@ class OAuthController extends Controller
return $this->errorResponse('Invalid refresh token', 400); 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(); $client = OAuthClient::query()->where('client_id', $request->client_id)->where('is_active', true)->first();
if (! $client) { if (! $client) {
return $this->errorResponse('Invalid client', 401); return $this->errorResponse('Invalid client', 401);
@@ -292,7 +301,7 @@ class OAuthController extends Controller
if (! $tenant) { if (! $tenant) {
Log::error('[OAuth] Tenant not found during token issuance', [ Log::error('[OAuth] Tenant not found during token issuance', [
'client_id' => $request->client_id, 'client_id' => $request->client_id,
'code_id' => $cachedCode['id'] ?? null, 'refresh_token_id' => $storedRefreshToken->id,
'tenant_id' => $storedRefreshToken->tenant_id, 'tenant_id' => $storedRefreshToken->tenant_id,
]); ]);
@@ -560,4 +569,4 @@ class OAuthController extends Controller
return redirect('/admin')->with('error', 'Connection error: '.$e->getMessage()); 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; namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant; use App\Models\Tenant;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -12,25 +11,32 @@ class CreditCheckMiddleware
{ {
public function handle(Request $request, Closure $next): Response 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); $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) { if ($this->requiresCredits($request) && $tenant->event_credits_balance < 1) {
return response()->json(['message' => 'Insufficient event credits'], 422); return response()->json([
} 'error' => 'Insufficient event credits. Please purchase more credits.',
], 402);
$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]);
} }
return $next($request); 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 private function resolveTenant(Request $request): Tenant
{ {
$user = $request->user(); $user = $request->user();

View File

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

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); return $this->belongsTo(Tenant::class);
} }
public function eventType(): BelongsTo
{
return $this->belongsTo(EventType::class);
}
public function photos(): HasMany public function photos(): HasMany
{ {
return $this->hasMany(Photo::class); return $this->hasMany(Photo::class);
@@ -46,4 +51,3 @@ class Event extends Model
->withTimestamps(); ->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 public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool
{ {
if ($this->event_credits_balance < $amount) { if ($amount <= 0) {
return false; 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([ EventCreditsLedger::create([
'tenant_id' => $this->id, 'tenant_id' => $this->id,
'delta' => -$amount, 'delta' => -$amount,
@@ -91,13 +100,33 @@ class Tenant extends Model
'note' => $note, '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 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([ EventCreditsLedger::create([
'tenant_id' => $this->id, 'tenant_id' => $this->id,
'delta' => $amount, 'delta' => $amount,
@@ -106,7 +135,25 @@ class Tenant extends Model
'note' => $note, '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; namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@@ -19,6 +22,18 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function boot(): void 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'));
});
} }
} }

View File

@@ -40,4 +40,10 @@ return [
'secret' => env('STRIPE_SECRET'), 'secret' => env('STRIPE_SECRET'),
'webhook' => env('STRIPE_WEBHOOK_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'),
],
]; ];

View File

@@ -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) # Backend-Erweiterung Implementation Roadmap (Aktualisiert: 2025-09-15 - Fortschritt)
## Implementierungsstand (Aktualisiert: 2025-09-15) ## Implementierungsstand (Aktualisiert: 2025-09-15)
Basierend auf aktueller Code-Analyse und Implementierung: 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 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 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 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 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 | Fokus | Dauer | Dependencies | Status | Milestone |
|-------|-------|-------|--------------|--------|-----------| |-------|-------|-------|--------------|--------|-----------|
| **Phase 1: Foundation** | Database & Authentication | 0 Tage | Laravel Sanctum/Passport | Vollständig abgeschlossen | OAuth-Flow funktioniert, Tokens validierbar | | **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 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 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 4: Admin & Monitoring** | SuperAdmin & Analytics | 4-5 Tage | Phase 3 | In Arbeit | Filament-Resources erweitert, Dashboard funktioniert |
## Phase 1: Foundation (Abgeschlossen) ## Phase 1: Foundation (Abgeschlossen)
### Status: Vollständig implementiert ### Status: Vollständig implementiert
- [x] DB-Migrationen ausgeführt (OAuth, PurchaseHistory, Subscriptions) - [x] DB-Migrationen ausgeführt (OAuth, PurchaseHistory, Subscriptions)
- [x] Models erstellt (OAuthClient, RefreshToken, TenantToken, PurchaseHistory) - [x] Models erstellt (OAuthClient, RefreshToken, TenantToken, PurchaseHistory)
- [x] Sanctum konfiguriert (api guard, HasApiTokens Trait) - [x] Sanctum konfiguriert (api guard, HasApiTokens Trait)
- [x] OAuthController implementiert (authorize, token, me mit PKCE/JWT) - [x] OAuthController implementiert (authorize, token, me mit PKCE/JWT)
- [x] Middleware implementiert (TenantTokenGuard, TenantIsolation) - [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 - **Testbar**: OAuth-Flow funktioniert mit Postman
## Phase 2: Core API (80% abgeschlossen, 2-3 Tage verbleibend) ## Phase 2: Core API (80% abgeschlossen, 2-3 Tage verbleibend)
### Ziele ### 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 - File Upload Pipeline mit Moderation
### Implementierter Fortschritt ### 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] API-Routen: Events/Photos/Tasks/Settings (tenant-scoped, slug-basiert)
- [x] Pagination, Filtering, Search, Error-Handling - [x] Pagination, Filtering, Search, Error-Handling
- [x] **Feature-Tests**: 21 Tests (SettingsApiTest: 8, TaskApiTest: 13, 100% Coverage) - [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 ### Verbleibende Tasks
- Phase 2 vollständig abgeschlossen - Phase 2 vollständig abgeschlossen
### Milestones ### Milestones
- [x] Events/Photos Endpunkte funktionieren - [x] Events/Photos Endpunkte funktionieren
- [x] Photo-Upload und Moderation testbar - [x] Photo-Upload und Moderation testbar
- [x] Task/Settings implementiert (CRUD, Assignment, Branding, Custom Domain) - [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) ## Phase 3: Business Logic (30% implementiert, 3-4 Tage)
### Ziele ### Ziele
- Freemium-Modell vollständig aktivieren - Freemium-Modell vollständig aktivieren
- Credit-Management, Webhooks, Security - Credit-Management, Webhooks, Security
### Implementierter Fortschritt ### Implementierter Fortschritt
- [x] Credit-Feld in Tenant-Model mit `event_credits_balance` - [x] Credit-Feld in Tenant-Model mit `event_credits_balance`
- [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert - [x] **Tenant::decrementCredits()/incrementCredits() Methoden** implementiert
- [x] Credit-Check in EventController (decrement bei Create) - [x] Credit-Check in EventController (decrement bei Create)
- [ ] CreditMiddleware für alle Event-Operationen - [ ] CreditMiddleware für alle Event-Operationen
### Verbleibende Tasks ### Verbleibende Tasks
1. **Credit-System erweitern (1 Tag)** 1. **Credit-System erweitern (1 Tag)**
- CreditMiddleware für alle Event-Create/Update - CreditMiddleware für alle Event-Create/Update
- CreditController für Balance, Ledger, History - CreditController für Balance, Ledger, History
- Tenant::decrementCredits() Methode mit Logging - Tenant::decrementCredits() Methode mit Logging
2. **Webhook-Integration (1-2 Tage)** 2. **Webhook-Integration (1-2 Tage)**
- RevenueCatController für Purchase-Webhooks - RevenueCatController für Purchase-Webhooks
- Signature-Validation, Balance-Update, Subscription-Sync - Signature-Validation, Balance-Update, Subscription-Sync
- Queue-basierte Retry-Logic - Queue-basierte Retry-Logic
3. **Security Implementation (1 Tag)** 3. **Security Implementation (1 Tag)**
- Rate Limiting: 100/min tenant, 10/min oauth - Rate Limiting: 100/min tenant, 10/min oauth
- Token-Rotation in OAuthController - Token-Rotation in OAuthController
- IP-Binding für Refresh Tokens - IP-Binding für Refresh Tokens
### Milestones ### Milestones
- [x] Credit-Check funktioniert (Event-Create scheitert bei 0) - [x] Credit-Check funktioniert (Event-Create scheitert bei 0)
@@ -95,7 +98,7 @@ Basierend auf aktueller Code-Analyse und Implementierung:
### Implementierter Fortschritt ### Implementierter Fortschritt
- [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute - [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 - [ ] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies
### Verbleibende Tasks ### Verbleibende Tasks
@@ -113,45 +116,45 @@ Basierend auf aktueller Code-Analyse und Implementierung:
- Bulk-Export, Token-Revoke - Bulk-Export, Token-Revoke
4. **Testing & Deployment (1 Tag)** 4. **Testing & Deployment (1 Tag)**
- Unit/Feature-Tests für alle Phasen - Unit/Feature-Tests für alle Phasen
- Deployment-Skript, Monitoring-Setup - Deployment-Skript, Monitoring-Setup
### Milestones ### Milestones
- [x] TenantResource basis erweitert - [x] TenantResource basis erweitert
- [ ] PurchaseHistoryResource funktioniert - [ ] PurchaseHistoryResource funktioniert
- [ ] Widgets zeigen Stats - [ ] Widgets zeigen Stats
- [ ] Policies schützen SuperAdmin - [ ] Policies schützen SuperAdmin
- [ ] >80% Testabdeckung - [ ] >80% Testabdeckung
## Gesamter Zeitplan ## Gesamter Zeitplan
| Woche | Phase | Status | | Woche | Phase | Status |
|-------|-------|--------| |-------|-------|--------|
| **1** | Foundation | Abgeschlossen | | **1** | Foundation | ✅ Abgeschlossen |
| **1** | Core API | Abgeschlossen | | **1** | Core API | ✅ Abgeschlossen |
| **2** | Business Logic | 40% In Arbeit | | **2** | Business Logic | 40% ⏳ In Arbeit |
| **2** | Admin & Monitoring | 20% 🔄 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 **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 ## Risiken & Mitigation
| Risiko | Wahrscheinlichkeit | Impact | 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 | | OAuth Security | Niedrig | Hoch | JWT Keys rotieren, Security-Review |
| Credit-Logik-Fehler | Niedrig | Hoch | Unit-Tests, Manual Testing mit Credits | | 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 1. **Phase 3 Business Logic (2-3 Tage)**: CreditMiddleware, CreditController, Webhooks
2. **Phase 4 Admin & Monitoring (2 Tage)**: PurchaseHistoryResource, Widgets, Policies 2. **Phase 4 Admin & Monitoring (2 Tage)**: PurchaseHistoryResource, Widgets, Policies
3. **Stakeholder-Review**: OAuth-Flow, Upload, Task/Settings testen 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 5. **Final Testing**: 100% Coverage, Integration Tests
6. **Deployment**: Staging-Environment, Monitoring-Setup 6. **Deployment**: Staging-Environment, Monitoring-Setup
**Gesamtkosten:** Ca. 60-100 Stunden (weit reduziert durch bestehende Basis). **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. **Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management.

View File

@@ -1,18 +1,24 @@
<?php <?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\EventPublicController; use App\Http\Controllers\Api\EventPublicController;
use App\Http\Controllers\Api\Tenant\EventController;
use App\Http\Controllers\Api\Tenant\SettingsController;
use App\Http\Controllers\Api\Tenant\TaskController;
use App\Http\Controllers\OAuthController; use App\Http\Controllers\OAuthController;
use App\Http\Controllers\RevenueCatWebhookController;
use App\Http\Controllers\Tenant\CreditController; use App\Http\Controllers\Tenant\CreditController;
use Illuminate\Support\Facades\Route;
// API routes without CSRF protection for guest PWA (stateless)
Route::prefix('v1')->name('api.v1.')->group(function () { Route::prefix('v1')->name('api.v1.')->group(function () {
// OAuth routes (public) Route::post('/webhooks/revenuecat', [RevenueCatWebhookController::class, 'handle'])
Route::get('/oauth/authorize', [OAuthController::class, 'authorize'])->name('oauth.authorize'); ->middleware('throttle:60,1')
Route::post('/oauth/token', [OAuthController::class, 'token'])->name('oauth.token'); ->name('webhooks.revenuecat');
// Guest PWA routes (public, throttled, no tenant middleware) 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::middleware('throttle:100,1')->group(function () {
Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show'); Route::get('/events/{slug}', [EventPublicController::class, 'event'])->name('events.show');
Route::get('/events/{slug}/stats', [EventPublicController::class, 'stats'])->name('events.stats'); 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'); 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', 'throttle:tenant-api'])->prefix('tenant')->group(function () {
Route::middleware(['tenant.token', 'tenant.isolation'])->prefix('tenant')->group(function () {
Route::get('me', [OAuthController::class, 'me'])->name('tenant.me'); Route::get('me', [OAuthController::class, 'me'])->name('tenant.me');
// Events CRUD Route::apiResource('events', EventController::class)
Route::apiResource('events', \App\Http\Controllers\Api\Tenant\EventController::class)->only(['index', 'show', 'update', 'destroy'])->parameters([ ->only(['index', 'show', 'destroy'])
'events' => 'event:slug', ->parameters(['events' => 'event:slug']);
]);
Route::middleware('credit.check')->post('events', [\App\Http\Controllers\Api\Tenant\EventController::class, 'store'])->name('tenant.events.store'); Route::middleware('credit.check')->group(function () {
Route::post('events', [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::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update');
Route::get('events/search', [\App\Http\Controllers\Api\Tenant\EventController::class, 'search'])->name('tenant.events.search'); });
// Tasks CRUD and operations Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status');
Route::apiResource('tasks', \App\Http\Controllers\Api\Tenant\TaskController::class); Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search');
Route::post('tasks/{task}/assign-event/{event}', [\App\Http\Controllers\Api\Tenant\TaskController::class, 'assignToEvent'])
Route::apiResource('tasks', TaskController::class);
Route::post('tasks/{task}/assign-event/{event}', [TaskController::class, 'assignToEvent'])
->name('tenant.tasks.assign-to-event'); ->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'); ->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'); ->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'); ->name('tenant.tasks.from-collection');
// Settings routes
Route::prefix('settings')->group(function () { Route::prefix('settings')->group(function () {
Route::get('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'index']) Route::get('/', [SettingsController::class, 'index'])
->name('tenant.settings.index'); ->name('tenant.settings.index');
Route::post('/', [\App\Http\Controllers\Api\Tenant\SettingsController::class, 'update']) Route::post('/', [SettingsController::class, 'update'])
->name('tenant.settings.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'); ->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'); ->name('tenant.settings.validate-domain');
}); });
@@ -70,5 +76,3 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
}); });
}); });
}); });

View File

@@ -11,9 +11,9 @@ class EmotionResourceTest extends TestCase
{ {
use RefreshDatabase; 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_'); $tempFile = tempnam(sys_get_temp_dir(), 'emotions_');
file_put_contents($tempFile, $csvData); file_put_contents($tempFile, $csvData);
@@ -24,15 +24,15 @@ class EmotionResourceTest extends TestCase
$this->assertSame(0, $failed); $this->assertSame(0, $failed);
$this->assertDatabaseHas('emotions', [ $this->assertDatabaseHas('emotions', [
'name' => json_encode(['de' => 'Glück', 'en' => 'Joy']), 'name' => json_encode(['de' => 'Glueck', 'en' => 'Joy']),
'icon' => '😊', 'icon' => 'smile',
'color' => '#FFD700', '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, 'sort_order' => 1,
'is_active' => 1, 'is_active' => 1,
]); ]);
$emotion = Emotion::where('name->de', 'Glück')->first(); $emotion = Emotion::where('name->de', 'Glueck')->first();
$this->assertNotNull($emotion); $this->assertNotNull($emotion);
} }
} }

View File

@@ -139,5 +139,16 @@ KEY;
$this->assertArrayHasKey('access_token', $refreshData); $this->assertArrayHasKey('access_token', $refreshData);
$this->assertArrayHasKey('refresh_token', $refreshData); $this->assertArrayHasKey('refresh_token', $refreshData);
$this->assertNotEquals($refreshData['access_token'], $tokenData['access_token']); $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',
]);
} }
} }

View File

@@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use App\Jobs\ProcessRevenueCatWebhook;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class RevenueCatWebhookTest extends TestCase
{
use RefreshDatabase;
public function test_webhook_dispatches_job_with_valid_signature(): void
{
config()->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);
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\EventType;
use Illuminate\Support\Carbon;
class EventCreditsTest extends TenantTestCase
{
public function test_event_creation_requires_credits(): void
{
$this->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',
]);
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Tests\Unit;
use App\Jobs\ProcessRevenueCatWebhook;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class ProcessRevenueCatWebhookTest extends TestCase
{
use RefreshDatabase;
public function test_job_creates_purchase_and_updates_balance(): void
{
config()->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);
}
}