Harden credit flows and add RevenueCat webhook
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Check credits balance
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
if ($tenant->event_credits_balance <= 0) {
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
69
app/Http/Controllers/RevenueCatWebhookController.php
Normal file
69
app/Http/Controllers/RevenueCatWebhookController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
return response()->json(['message' => 'Insufficient event credits'], 422);
|
||||
$request->attributes->set('tenant', $tenant);
|
||||
$request->attributes->set('tenant_id', $tenant->id);
|
||||
$request->merge([
|
||||
'tenant' => $tenant,
|
||||
'tenant_id' => $tenant->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$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();
|
||||
|
||||
@@ -2,55 +2,39 @@
|
||||
|
||||
namespace App\Http\Resources\Tenant;
|
||||
|
||||
use App\Http\Resources\Tenant\EventTypeResource;
|
||||
use App\Http\Resources\Tenant\PhotoResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$tenantId = $request->attributes->get('tenant_id');
|
||||
|
||||
// Hide sensitive data for other tenants
|
||||
$showSensitive = $this->tenant_id === $tenantId;
|
||||
$settings = is_array($this->settings) ? $this->settings : [];
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'event_date' => $this->event_date ? $this->event_date->toISOString() : null,
|
||||
'event_date' => $this->date ? $this->date->toISOString() : null,
|
||||
'location' => $this->location,
|
||||
'max_participants' => $this->max_participants,
|
||||
'current_participants' => $showSensitive ? $this->photos_count : null,
|
||||
'public_url' => $this->public_url,
|
||||
'custom_domain' => $showSensitive ? $this->custom_domain : null,
|
||||
'theme_color' => $this->theme_color,
|
||||
'status' => $showSensitive ? $this->status : 'published',
|
||||
'password_protected' => $this->password_protected,
|
||||
'features' => $this->features,
|
||||
'event_type' => new EventTypeResource($this->whenLoaded('eventType')),
|
||||
'photos' => PhotoResource::collection($this->whenLoaded('photos')),
|
||||
'tasks' => $showSensitive ? $this->whenLoaded('tasks') : [],
|
||||
'tenant' => $showSensitive ? [
|
||||
'id' => $this->tenant->id,
|
||||
'name' => $this->tenant->name,
|
||||
'event_credits_balance' => $this->tenant->event_credits_balance,
|
||||
] : null,
|
||||
'created_at' => $this->created_at->toISOString(),
|
||||
'updated_at' => $this->updated_at->toISOString(),
|
||||
'photo_count' => $this->photos_count,
|
||||
'like_count' => $this->photos->sum('likes_count'),
|
||||
'is_public' => $this->status === 'published' && !$this->password_protected,
|
||||
'public_share_url' => $showSensitive ? route('api.v1.events.show', ['slug' => $this->slug]) : null,
|
||||
'qr_code_url' => $showSensitive ? route('api.v1.events.qr', ['event' => $this->id]) : null,
|
||||
'current_participants' => $showSensitive ? ($this->photos_count ?? null) : null,
|
||||
'public_url' => $settings['public_url'] ?? null,
|
||||
'custom_domain' => $showSensitive ? ($settings['custom_domain'] ?? null) : null,
|
||||
'theme_color' => $settings['theme_color'] ?? null,
|
||||
'status' => $this->status ?? 'draft',
|
||||
'features' => $settings['features'] ?? [],
|
||||
'event_type_id' => $this->event_type_id,
|
||||
'created_at' => $this->created_at?->toISOString(),
|
||||
'updated_at' => $this->updated_at?->toISOString(),
|
||||
'photo_count' => $this->photos_count ?? 0,
|
||||
'like_count' => $this->whenLoaded('photos', fn () => $this->photos->sum('likes_count'), 0),
|
||||
'is_public' => $this->status === 'published',
|
||||
'public_share_url' => null,
|
||||
'qr_code_url' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
248
app/Jobs/ProcessRevenueCatWebhook.php
Normal file
248
app/Jobs/ProcessRevenueCatWebhook.php
Normal file
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\EventPurchase;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ProcessRevenueCatWebhook implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
private array $payload;
|
||||
|
||||
private ?string $eventId;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function __construct(array $payload, ?string $eventId = null)
|
||||
{
|
||||
$this->payload = $payload;
|
||||
$this->eventId = $eventId !== '' ? $eventId : null;
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$appUserId = $this->value('event.app_user_id')
|
||||
?? $this->value('subscriber.app_user_id');
|
||||
|
||||
if (! is_string($appUserId) || $appUserId === '') {
|
||||
Log::warning('RevenueCat webhook missing app_user_id', [
|
||||
'event_id' => $this->eventId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $this->resolveTenant($appUserId);
|
||||
if (! $tenant) {
|
||||
Log::warning('RevenueCat webhook tenant not found', [
|
||||
'event_id' => $this->eventId,
|
||||
'app_user_id' => $appUserId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$productId = $this->value('event.product_id')
|
||||
?? $this->value('event.entitlement_id');
|
||||
$credits = $this->mapCreditsFromProduct($productId);
|
||||
|
||||
if ($credits <= 0) {
|
||||
Log::info('RevenueCat webhook ignored due to unmapped product', [
|
||||
'event_id' => $this->eventId,
|
||||
'product_id' => $productId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$transactionId = $this->value('event.transaction_id')
|
||||
?? $this->value('event.id')
|
||||
?? $this->eventId
|
||||
?? (string) Str::uuid();
|
||||
|
||||
if (EventPurchase::where('provider', 'revenuecat')
|
||||
->where('external_receipt_id', $transactionId)
|
||||
->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amount = (float) ($this->value('event.price') ?? 0);
|
||||
$currency = strtoupper((string) ($this->value('event.currency') ?? 'EUR'));
|
||||
|
||||
DB::transaction(function () use ($tenant, $credits, $transactionId, $productId, $amount, $currency) {
|
||||
$tenant->refresh();
|
||||
|
||||
$purchase = EventPurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'events_purchased' => $credits,
|
||||
'amount' => $amount,
|
||||
'currency' => $currency,
|
||||
'provider' => 'revenuecat',
|
||||
'external_receipt_id' => $transactionId,
|
||||
'status' => 'completed',
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
$note = sprintf('RevenueCat product: %s', $productId ?? 'unknown');
|
||||
$tenant->incrementCredits($credits, 'purchase', $note, $purchase->id);
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$this->updateSubscriptionStatus($tenant);
|
||||
|
||||
Log::info('RevenueCat webhook processed', [
|
||||
'event_id' => $this->eventId,
|
||||
'tenant_id' => $tenant->id,
|
||||
'product_id' => $productId,
|
||||
'credits' => $credits,
|
||||
]);
|
||||
}
|
||||
|
||||
private function updateSubscriptionStatus(Tenant $tenant): void
|
||||
{
|
||||
$expirationMs = $this->value('event.expiration_at_ms');
|
||||
$expirationIso = $this->value('event.expiration_at');
|
||||
|
||||
$expiresAt = null;
|
||||
if (is_numeric($expirationMs)) {
|
||||
$expiresAt = Carbon::createFromTimestampMs((int) $expirationMs, 'UTC');
|
||||
} elseif (is_string($expirationIso) && $expirationIso !== '') {
|
||||
try {
|
||||
$expiresAt = Carbon::parse($expirationIso);
|
||||
} catch (\Throwable $e) {
|
||||
$expiresAt = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $expiresAt instanceof Carbon) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expiresAt = $expiresAt->copy()->setTimezone('UTC')->floorSecond();
|
||||
|
||||
$tier = $expiresAt->isFuture() ? 'pro' : 'free';
|
||||
|
||||
$tenant->update([
|
||||
'subscription_tier' => $tier,
|
||||
'subscription_expires_at' => $expiresAt,
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveTenant(string $appUserId): ?Tenant
|
||||
{
|
||||
$prefix = (string) config('services.revenuecat.app_user_prefix', 'tenant');
|
||||
$lower = strtolower($appUserId);
|
||||
|
||||
if (is_numeric($appUserId)) {
|
||||
$byId = Tenant::find((int) $appUserId);
|
||||
if ($byId) {
|
||||
return $byId;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ([':', '-', '_'] as $delimiter) {
|
||||
$needle = strtolower($prefix) . $delimiter;
|
||||
if (str_starts_with($lower, $needle)) {
|
||||
$candidate = substr($appUserId, strlen($needle));
|
||||
if (is_numeric($candidate)) {
|
||||
$byNumeric = Tenant::find((int) $candidate);
|
||||
if ($byNumeric) {
|
||||
return $byNumeric;
|
||||
}
|
||||
}
|
||||
|
||||
$bySlug = Tenant::where('slug', $candidate)->first();
|
||||
if ($bySlug) {
|
||||
return $bySlug;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Tenant::where('slug', $appUserId)->first();
|
||||
}
|
||||
|
||||
private function mapCreditsFromProduct(?string $productId): int
|
||||
{
|
||||
if (! is_string($productId) || $productId === '') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$mappings = $this->productMappings();
|
||||
if (array_key_exists($productId, $mappings)) {
|
||||
return (int) $mappings[$productId];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function productMappings(): array
|
||||
{
|
||||
$raw = (string) config('services.revenuecat.product_mappings', '');
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$normalized = [];
|
||||
|
||||
foreach ($decoded as $key => $value) {
|
||||
if (! is_scalar($value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[(string) $key] = (int) $value;
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
$mappings = [];
|
||||
foreach (explode(',', $raw) as $pair) {
|
||||
$pair = trim($pair);
|
||||
if ($pair === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$key, $value] = array_pad(explode(':', $pair, 2), 2, null);
|
||||
if ($key === null || $value === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mappings[trim($key)] = (int) trim($value);
|
||||
}
|
||||
|
||||
return $mappings;
|
||||
}
|
||||
|
||||
private function value(string $path, $default = null)
|
||||
{
|
||||
$segments = explode('.', $path);
|
||||
$value = $this->payload;
|
||||
|
||||
foreach ($segments as $segment) {
|
||||
if (! is_array($value) || ! array_key_exists($segment, $value)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$value = $value[$segment];
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
$locked = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked || $locked->event_credits_balance < $amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
**Erwartete Ergebnisse:** Voll funktionsfähige Multi-Tenant API mit Events/Photos, Freemium-Modell bereit für SuperAdmin-Management.
|
||||
@@ -1,18 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
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\RevenueCatWebhookController;
|
||||
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 () {
|
||||
// OAuth routes (public)
|
||||
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');
|
||||
});
|
||||
|
||||
// Guest PWA routes (public, throttled, no tenant middleware)
|
||||
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::apiResource('events', EventController::class)
|
||||
->only(['index', 'show', 'destroy'])
|
||||
->parameters(['events' => 'event:slug']);
|
||||
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
// 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::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 () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
57
tests/Feature/RevenueCatWebhookTest.php
Normal file
57
tests/Feature/RevenueCatWebhookTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/Tenant/EventCreditsTest.php
Normal file
46
tests/Feature/Tenant/EventCreditsTest.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
62
tests/Unit/ProcessRevenueCatWebhookTest.php
Normal file
62
tests/Unit/ProcessRevenueCatWebhookTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user