diff --git a/app/Console/Commands/AddDummyTenantUser.php b/app/Console/Commands/AddDummyTenantUser.php deleted file mode 100644 index 305affe..0000000 --- a/app/Console/Commands/AddDummyTenantUser.php +++ /dev/null @@ -1,120 +0,0 @@ -option('email'); - $password = (string) $this->option('password'); - $tenantName = (string) $this->option('tenant'); - $userName = (string) $this->option('name'); - $firstName = (string) $this->option('first_name'); - $lastName = (string) $this->option('last_name'); - $address = (string) $this->option('address'); - $phone = (string) $this->option('phone'); - - $this->info('Starting dummy tenant creation with email: ' . $email); - - // Pre-flight checks for common failures - if (! Schema::hasTable('users')) { - $this->error("Table 'users' does not exist. Run: php artisan migrate"); - return self::FAILURE; - } - if (! Schema::hasTable('tenants')) { - $this->error("Table 'tenants' does not exist. Run: php artisan migrate"); - return self::FAILURE; - } - - DB::beginTransaction(); - try { - // Create or fetch tenant - $slug = Str::slug($tenantName ?: 'demo-tenant'); - /** @var Tenant $tenant */ - $tenant = Tenant::query()->where('slug', $slug)->first(); - if (! $tenant) { - $tenant = new Tenant(); - $tenant->name = $tenantName; - $tenant->slug = $slug; - $tenant->domain = null; - $tenant->contact_name = $userName; - $tenant->contact_email = $email; - $tenant->contact_phone = $phone ?: null; - $tenant->event_credits_balance = 1; - $tenant->max_photos_per_event = 500; - $tenant->max_storage_mb = 1024; - $tenant->features = ['custom_branding' => false]; - $tenant->is_active = true; - $tenant->is_suspended = false; - $tenant->save(); - $this->info('Created new tenant: ' . $tenant->name); - } else { - $this->info('Using existing tenant: ' . $tenant->name); - } - - // Create or fetch user - /** @var User $user */ - $user = User::query()->where('email', $email)->first(); - $updatePassword = (bool) $this->option('update-password'); - if (! $user) { - $user = new User(); - if (Schema::hasColumn($user->getTable(), 'name')) $user->name = $userName; - $user->email = $email; - $user->password = Hash::make($password); - $this->info('Creating new user: ' . $email); - } else if ($updatePassword) { - $user->password = Hash::make($password); - $this->info('Updating password for existing user: ' . $email); - } - if (Schema::hasColumn($user->getTable(), 'first_name')) $user->first_name = $firstName; - if (Schema::hasColumn($user->getTable(), 'last_name')) $user->last_name = $lastName; - if (Schema::hasColumn($user->getTable(), 'address')) $user->address = $address; - if (Schema::hasColumn($user->getTable(), 'phone')) $user->phone = $phone; - if (Schema::hasColumn($user->getTable(), 'tenant_id')) { - $user->tenant_id = $tenant->id; - } - if (Schema::hasColumn($user->getTable(), 'role')) { - $user->role = 'tenant_admin'; - } - $user->save(); - $this->info('User saved successfully.'); - - DB::commit(); - } catch (\Throwable $e) { - DB::rollBack(); - $this->error('Failed: '.$e->getMessage()); - $this->error('Stack trace: ' . $e->getTraceAsString()); - return self::FAILURE; - } - - $this->info('Dummy tenant user created/updated.'); - $this->line('Tenant: '.$tenant->name.' (#'.$tenant->id.')'); - $this->line('Email: '.$email); - $this->line('Password: '.$password); - return self::SUCCESS; - } -} diff --git a/app/Console/Commands/CheckEventPackages.php b/app/Console/Commands/CheckEventPackages.php index 635f849..f43b458 100644 --- a/app/Console/Commands/CheckEventPackages.php +++ b/app/Console/Commands/CheckEventPackages.php @@ -30,56 +30,8 @@ class CheckEventPackages extends Command ->sortDesc() ->values(); - $creditThresholds = collect(config('package-limits.credit_thresholds', [])) - ->filter(fn ($value) => is_numeric($value) && $value >= 0) - ->map(fn ($value) => (int) $value) - ->unique() - ->sortDesc() - ->values(); - - $maxCreditThreshold = $creditThresholds->max(); - $now = now(); - if ($maxCreditThreshold !== null) { - \App\Models\Tenant::query() - ->select(['id', 'event_credits_balance', 'credit_warning_sent_at', 'credit_warning_threshold', 'contact_email']) - ->chunkById(100, function ($tenants) use ($creditThresholds, $maxCreditThreshold, $now) { - foreach ($tenants as $tenant) { - $balance = (int) ($tenant->event_credits_balance ?? 0); - - if ($balance > $maxCreditThreshold && $tenant->credit_warning_sent_at) { - $tenant->forceFill([ - 'credit_warning_sent_at' => null, - 'credit_warning_threshold' => null, - ])->save(); - - PackageLimitMetrics::recordCreditRecovery($balance); - - continue; - } - - foreach ($creditThresholds as $threshold) { - if ( - $balance <= $threshold - && ( - $tenant->credit_warning_sent_at === null - || $threshold < ($tenant->credit_warning_threshold ?? PHP_INT_MAX) - ) - ) { - event(new \App\Events\Packages\TenantCreditsLow($tenant, $balance, $threshold)); - PackageLimitMetrics::recordCreditWarning($threshold, $balance); - $tenant->forceFill([ - 'credit_warning_sent_at' => $now, - 'credit_warning_threshold' => $threshold, - ])->save(); - break; - } - } - } - }); - } - EventPackage::query() ->whereNotNull('gallery_expires_at') ->chunkById(100, function ($packages) use ($warningDays, $now) { diff --git a/app/Console/Commands/MigrateToPackages.php b/app/Console/Commands/MigrateToPackages.php deleted file mode 100644 index a078412..0000000 --- a/app/Console/Commands/MigrateToPackages.php +++ /dev/null @@ -1,93 +0,0 @@ -where('type', 'endcustomer')->first(); - if (!$freePackage) { - $this->error('Free package not found. Run seeder first.'); - return 1; - } - - $resellerPackage = Package::where('name', 'Reseller S')->where('type', 'reseller')->first(); - if (!$resellerPackage) { - $this->error('Reseller package not found. Run seeder first.'); - return 1; - } - - // Migrate tenants with credits to tenant_packages (reseller free) - $tenants = Tenant::where('event_credits_balance', '>', 0)->get(); - foreach ($tenants as $tenant) { - $initialEvents = floor($tenant->event_credits_balance / 100); // Arbitrary conversion - TenantPackage::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $resellerPackage->id, - 'price' => 0, - 'purchased_at' => now(), - 'expires_at' => now()->addYear(), - 'used_events' => 0, - 'active' => true, - ]); - - PackagePurchase::create([ - 'tenant_id' => $tenant->id, - 'package_id' => $resellerPackage->id, - 'type' => 'reseller_subscription', - 'provider_id' => 'migration', - 'price' => 0, - 'metadata' => ['migrated_credits' => $tenant->event_credits_balance], - ]); - - $this->info("Migrated tenant {$tenant->name} with {$tenant->event_credits_balance} credits to Reseller S package."); - } - - // Migrate events to event_packages (free) - $events = Event::all(); - foreach ($events as $event) { - EventPackage::create([ - 'event_id' => $event->id, - 'package_id' => $freePackage->id, - 'price' => 0, - 'purchased_at' => $event->created_at, - 'used_photos' => 0, - ]); - - PackagePurchase::create([ - 'tenant_id' => $event->tenant_id, - 'event_id' => $event->id, - 'package_id' => $freePackage->id, - 'type' => 'endcustomer', - 'provider_id' => 'migration', - 'price' => 0, - 'metadata' => ['migrated_from_credits' => true], - ]); - - $this->info("Migrated event {$event->name} to Free package."); - } - - // Clear old credits data (assume drop migration already run) - Tenant::where('event_credits_balance', '>', 0)->update(['event_credits_balance' => 0]); - - $this->info('Migration completed successfully.'); - }); - - return 0; - } -} \ No newline at end of file diff --git a/app/Console/Commands/RetryTenantNotification.php b/app/Console/Commands/RetryTenantNotification.php index 5d56117..e8a470a 100644 --- a/app/Console/Commands/RetryTenantNotification.php +++ b/app/Console/Commands/RetryTenantNotification.php @@ -8,7 +8,6 @@ use App\Jobs\Packages\SendEventPackageGuestLimitNotification; use App\Jobs\Packages\SendEventPackageGuestThresholdWarning; use App\Jobs\Packages\SendEventPackagePhotoLimitNotification; use App\Jobs\Packages\SendEventPackagePhotoThresholdWarning; -use App\Jobs\Packages\SendTenantCreditsLowNotification; use App\Jobs\Packages\SendTenantPackageEventLimitNotification; use App\Jobs\Packages\SendTenantPackageEventThresholdWarning; use App\Jobs\Packages\SendTenantPackageExpiredNotification; @@ -16,7 +15,6 @@ use App\Jobs\Packages\SendTenantPackageExpiringNotification; use App\Models\TenantNotificationLog; use Illuminate\Console\Command; use Illuminate\Support\Arr; -use Illuminate\Support\Facades\Log; class RetryTenantNotification extends Command { @@ -78,7 +76,6 @@ class RetryTenantNotification extends Command 'guest_limit' => $this->dispatchGuestLimit($context), 'gallery_warning' => $this->dispatchGalleryWarning($context), 'gallery_expired' => $this->dispatchGalleryExpired($context), - 'credits_low' => $this->dispatchCreditsLow($log->tenant_id, $context), 'event_threshold' => $this->dispatchEventThreshold($context), 'event_limit' => $this->dispatchEventLimit($context), 'package_expiring' => $this->dispatchPackageExpiring($context), @@ -174,23 +171,6 @@ class RetryTenantNotification extends Command return true; } - private function dispatchCreditsLow(int $tenantId, array $context): bool - { - $balance = Arr::get($context, 'balance'); - $threshold = Arr::get($context, 'threshold'); - - if ($balance === null || $threshold === null) { - Log::warning('credits_low retry missing balance or threshold', compact('tenantId', 'context')); - } - - $balance = $balance !== null ? (int) $balance : 0; - $threshold = $threshold !== null ? (int) $threshold : 0; - - SendTenantCreditsLowNotification::dispatch($tenantId, $balance, $threshold); - - return true; - } - private function dispatchEventThreshold(array $context): bool { $tenantPackageId = Arr::get($context, 'tenant_package_id'); diff --git a/app/Console/Commands/SeedDemoSwitcherTenants.php b/app/Console/Commands/SeedDemoSwitcherTenants.php deleted file mode 100644 index 2406162..0000000 --- a/app/Console/Commands/SeedDemoSwitcherTenants.php +++ /dev/null @@ -1,613 +0,0 @@ -environment(['local', 'development', 'demo'])) { - $this->error('Cleanup/Seeding is restricted to local/development/demo environments.'); - - return self::FAILURE; - } - - if ($this->option('cleanup')) { - return $this->cleanup(); - } - - $this->info('Seeding demo tenants for switcher...'); - - $packages = $this->loadPackages(); - $eventTypes = $this->loadEventTypes(); - - DB::transaction(function () use ($packages, $eventTypes) { - $this->seedCustomerStandardEmpty($packages, $eventTypes); - $this->seedCustomerStarterWedding($packages, $eventTypes); - $this->seedResellerActive($packages, $eventTypes); - $this->seedResellerFull($packages, $eventTypes); - }); - - if ($this->option('with-photos')) { - $this->seedPhotosFromPexels((int) $this->option('photos-per-event')); - } - - $this->info('Done.'); - - return self::SUCCESS; - } - - private function cleanup(): int - { - $slugs = [ - 'demo-standard-empty', - 'demo-starter-wedding', - 'demo-reseller-active', - 'demo-reseller-full', - ]; - - $eventsDeleted = 0; - $photosDeleted = 0; - $photoLikesDeleted = 0; - $usersDeleted = 0; - - foreach ($slugs as $slug) { - $tenant = Tenant::where('slug', $slug)->first(); - - if (! $tenant) { - continue; - } - - foreach ($tenant->events as $event) { - $eventsDeleted++; - $photos = Photo::where('event_id', $event->id)->get(); - foreach ($photos as $photo) { - $deletedLikes = $photo->likes()->count(); - $photo->likes()->delete(); - $photoLikesDeleted += $deletedLikes; - - if ($photo->thumbnail_path) { - Storage::disk('public')->delete($photo->thumbnail_path); - } - - if ($photo->file_path) { - Storage::disk('public')->delete($photo->file_path); - } - - $photo->delete(); - $photosDeleted++; - } - - Storage::disk('public')->deleteDirectory("events/{$event->id}/gallery"); - Storage::disk('public')->deleteDirectory("events/{$event->id}/gallery/thumbs"); - - $event->taskCollections()->detach(); - $event->tasks()->detach(); - $event->eventPackages()->delete(); - $event->delete(); - } - - TenantPackage::where('tenant_id', $tenant->id)->delete(); - $usersDeleted += User::where('tenant_id', $tenant->id)->count(); - User::where('tenant_id', $tenant->id)->delete(); - $tenant->delete(); - } - - $this->info( - 'Cleanup completed. Tenants deleted: '.count($slugs) - .", Users deleted: {$usersDeleted}, Events deleted: {$eventsDeleted}, Photos deleted: {$photosDeleted}, Photo likes deleted: {$photoLikesDeleted}" - ); - - return self::SUCCESS; - } - - private function loadPackages(): array - { - $slugs = [ - 'starter' => 'Starter', - 'standard' => 'Standard', - 's-small-reseller' => 'Reseller S', - ]; - - $packages = []; - foreach ($slugs as $slug => $label) { - $package = Package::where('slug', $slug)->first(); - if (! $package) { - $this->error("Package {$label} ({$slug}) not found. Run PackageSeeder first."); - abort(1); - } - - $packages[$slug] = $package; - } - - return $packages; - } - - private function loadEventTypes(): array - { - $slugs = ['wedding', 'corporate', 'birthday', 'festival']; - $types = []; - - foreach ($slugs as $slug) { - $eventType = EventType::where('slug', $slug)->first(); - if ($eventType) { - $types[$slug] = $eventType; - } - } - - return $types; - } - - private function seedCustomerStandardEmpty(array $packages, array $eventTypes): void - { - $tenant = $this->upsertTenant( - slug: 'demo-standard-empty', - name: 'Demo Standard (ohne Event)', - contactEmail: 'standard-empty@demo.fotospiel', - attributes: [ - 'subscription_tier' => 'standard', - 'subscription_status' => 'active', - 'event_credits_balance' => 1, - ], - ); - - $this->upsertAdmin($tenant, 'standard-empty@demo.fotospiel'); - - TenantPackage::updateOrCreate( - ['tenant_id' => $tenant->id, 'package_id' => $packages['standard']->id], - [ - 'price' => $packages['standard']->price, - 'purchased_at' => Carbon::now()->subDays(1), - 'expires_at' => Carbon::now()->addMonths(12), - 'used_events' => 0, - 'active' => true, - ] - ); - - $this->comment('Seeded Standard tenant without events.'); - } - - private function seedCustomerStarterWedding(array $packages, array $eventTypes): void - { - $tenant = $this->upsertTenant( - slug: 'demo-starter-wedding', - name: 'Demo Starter Wedding', - contactEmail: 'starter-wedding@demo.fotospiel', - attributes: [ - 'subscription_tier' => 'starter', - 'subscription_status' => 'active', - 'event_credits_balance' => 0, - ], - ); - - $this->upsertAdmin($tenant, 'starter-wedding@demo.fotospiel'); - - $event = $this->upsertEvent( - tenant: $tenant, - package: $packages['starter'], - eventType: $eventTypes['wedding'] ?? null, - attributes: [ - 'name' => ['de' => 'Hochzeit Mia & Jonas', 'en' => 'Wedding Mia & Jonas'], - 'slug' => 'demo-starter-wedding', - 'status' => 'published', - 'is_active' => true, - 'date' => Carbon::now()->addWeeks(5), - ], - ); - - $this->attachDefaultCollections($event); - } - - private function seedResellerActive(array $packages, array $eventTypes): void - { - $tenant = $this->upsertTenant( - slug: 'demo-reseller-active', - name: 'Demo Reseller Active', - contactEmail: 'reseller-active@demo.fotospiel', - attributes: [ - 'subscription_tier' => 'reseller', - 'subscription_status' => 'active', - 'event_credits_balance' => 2, - ], - ); - - $this->upsertAdmin($tenant, 'reseller-active@demo.fotospiel'); - - TenantPackage::updateOrCreate( - ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], - [ - 'price' => $packages['s-small-reseller']->price, - 'purchased_at' => Carbon::now()->subMonths(1), - 'expires_at' => Carbon::now()->addMonths(11), - 'used_events' => 3, - 'active' => true, - ] - ); - - $events = [ - [ - 'name' => ['de' => 'Corporate Summit', 'en' => 'Corporate Summit'], - 'slug' => 'demo-reseller-corporate', - 'type' => $eventTypes['corporate'] ?? null, - 'date' => Carbon::now()->addWeeks(3), - ], - [ - 'name' => ['de' => 'Sommerfestival', 'en' => 'Summer Festival'], - 'slug' => 'demo-reseller-festival', - 'type' => $eventTypes['festival'] ?? ($eventTypes['birthday'] ?? null), - 'date' => Carbon::now()->addWeeks(6), - ], - [ - 'name' => ['de' => 'Geburtstag Lisa', 'en' => 'Lisa Birthday'], - 'slug' => 'demo-reseller-birthday', - 'type' => $eventTypes['birthday'] ?? null, - 'date' => Carbon::now()->addWeeks(9), - ], - ]; - - foreach ($events as $index => $config) { - $event = $this->upsertEvent( - tenant: $tenant, - package: $packages['standard'], - eventType: $config['type'], - attributes: [ - 'name' => $config['name'], - 'slug' => $config['slug'], - 'status' => 'published', - 'is_active' => true, - 'date' => $config['date'], - ], - ); - - $this->attachDefaultCollections($event); - } - } - - private function seedResellerFull(array $packages, array $eventTypes): void - { - $tenant = $this->upsertTenant( - slug: 'demo-reseller-full', - name: 'Demo Reseller Voll', - contactEmail: 'reseller-full@demo.fotospiel', - attributes: [ - 'subscription_tier' => 'reseller', - 'subscription_status' => 'active', - 'event_credits_balance' => 0, - ], - ); - - $this->upsertAdmin($tenant, 'reseller-full@demo.fotospiel'); - - TenantPackage::updateOrCreate( - ['tenant_id' => $tenant->id, 'package_id' => $packages['s-small-reseller']->id], - [ - 'price' => $packages['s-small-reseller']->price, - 'purchased_at' => Carbon::now()->subMonths(6), - 'expires_at' => Carbon::now()->addMonths(6), - 'used_events' => 5, - 'active' => true, - ] - ); - - $eventConfigs = [ - ['slug' => 'demo-full-wedding', 'name' => ['de' => 'Hochzeit Clara & Ben', 'en' => 'Wedding Clara & Ben'], 'type' => $eventTypes['wedding'] ?? null], - ['slug' => 'demo-full-corporate', 'name' => ['de' => 'Jahrestagung', 'en' => 'Annual Summit'], 'type' => $eventTypes['corporate'] ?? null], - ['slug' => 'demo-full-birthday', 'name' => ['de' => 'Geburtstag Jonas', 'en' => 'Birthday Jonas'], 'type' => $eventTypes['birthday'] ?? null], - ['slug' => 'demo-full-festival', 'name' => ['de' => 'Stadtfest', 'en' => 'City Festival'], 'type' => $eventTypes['festival'] ?? null], - ['slug' => 'demo-full-christmas', 'name' => ['de' => 'Weihnachtsfeier', 'en' => 'Christmas Party'], 'type' => $eventTypes['corporate'] ?? null], - ]; - - foreach ($eventConfigs as $index => $config) { - $event = $this->upsertEvent( - tenant: $tenant, - package: $packages['standard'], - eventType: $config['type'], - attributes: [ - 'name' => $config['name'], - 'slug' => $config['slug'], - 'status' => 'archived', - 'is_active' => false, - 'date' => Carbon::now()->subWeeks(5 - $index), - ], - ); - - $this->attachDefaultCollections($event); - } - } - - private function upsertTenant(string $slug, string $name, string $contactEmail, array $attributes = []): Tenant - { - $defaults = [ - 'name' => $name, - 'contact_email' => $contactEmail, - 'subscription_expires_at' => Carbon::now()->addMonths(12), - 'is_active' => true, - 'is_suspended' => false, - 'settings_updated_at' => Carbon::now(), - 'settings' => [ - 'branding' => [ - 'logo_url' => null, - 'primary_color' => '#1D4ED8', - 'secondary_color' => '#0F172A', - 'font_family' => 'Inter, sans-serif', - ], - 'features' => [ - 'photo_likes_enabled' => true, - 'event_checklist' => true, - ], - 'contact_email' => $contactEmail, - ], - ]; - - return Tenant::updateOrCreate( - ['slug' => $slug], - array_merge($defaults, $attributes, ['slug' => $slug]) - ); - } - - private function upsertAdmin(Tenant $tenant, string $email): User - { - $password = config('seeding.demo_tenant_password', 'Demo1234!'); - - $user = User::updateOrCreate( - ['email' => $email], - [ - 'tenant_id' => $tenant->id, - 'role' => 'tenant_admin', - 'password' => Hash::make($password), - 'first_name' => Str::headline(Str::before($tenant->slug, '-')), - 'last_name' => 'Demo', - ] - ); - - if (! $user->email_verified_at) { - $user->forceFill(['email_verified_at' => now()])->save(); - } - - return $user; - } - - private function upsertEvent(Tenant $tenant, Package $package, ?EventType $eventType, array $attributes): Event - { - $resolvedEventType = $eventType ?? $this->fallbackEventType(); - - $payload = array_merge([ - 'tenant_id' => $tenant->id, - 'event_type_id' => $resolvedEventType?->id, - 'settings' => [ - 'features' => [ - 'photo_likes_enabled' => true, - 'event_checklist' => true, - ], - ], - ], $attributes); - - /** @var Event $event */ - $event = Event::updateOrCreate( - ['slug' => $attributes['slug']], - $payload - ); - - EventPackage::updateOrCreate( - [ - 'event_id' => $event->id, - 'package_id' => $package->id, - ], - [ - 'purchased_price' => $package->price, - 'purchased_at' => Carbon::now()->subDays(2), - 'used_photos' => 0, - 'used_guests' => 0, - 'gallery_expires_at' => Carbon::now()->addDays($package->gallery_days ?? 30), - ] - ); - - return $event; - } - - private function fallbackEventType(): ?EventType - { - $fallback = EventType::first(); - - if (! $fallback) { - $this->warn('No EventType available, events will miss type. Please run EventTypesSeeder.'); - } - - return $fallback; - } - - private function attachDefaultCollections(Event $event): void - { - if (! $event->event_type_id) { - return; - } - - $collection = TaskCollection::where('event_type_id', $event->event_type_id) - ->where('is_default', true) - ->orderBy('position') - ->first(); - - if (! $collection) { - return; - } - - $event->taskCollections()->syncWithoutDetaching([$collection->id]); - - $taskIds = $collection->tasks()->pluck('tasks.id')->all(); - if ($taskIds !== []) { - $event->tasks()->syncWithoutDetaching($taskIds); - } - } - - private function seedPhotosFromPexels(int $targetPerEvent): void - { - $apiKey = config('services.pexels.key') ?? env('PEXELS_API_KEY'); - if (! $apiKey) { - $this->warn('PEXELS_API_KEY missing, skipping photo download.'); - - return; - } - - $events = Event::whereIn('slug', [ - 'demo-starter-wedding', - 'demo-reseller-corporate', - 'demo-reseller-festival', - 'demo-reseller-birthday', - 'demo-full-wedding', - 'demo-full-corporate', - 'demo-full-birthday', - 'demo-full-festival', - 'demo-full-christmas', - ])->get(); - - foreach ($events as $event) { - $query = $this->guessQueryForEvent($event); - $this->info("Downloading photos for {$event->slug} ({$query})..."); - - $photos = $this->fetchPexels($apiKey, $query, $targetPerEvent); - if ($photos === []) { - $this->warn('No photos returned from Pexels.'); - - continue; - } - - $this->storePhotos($event, $photos); - } - } - - private function guessQueryForEvent(Event $event): string - { - $typeSlug = optional($event->eventType)->slug; - - return match ($typeSlug) { - 'wedding' => 'wedding photography couple', - 'corporate' => 'corporate event people', - 'birthday' => 'birthday party friends', - default => 'event celebration crowd', - }; - } - - private function fetchPexels(string $apiKey, string $query, int $count): array - { - $perPage = min(40, max(5, $count)); - - $response = Http::withHeaders([ - 'Authorization' => $apiKey, - ])->get('https://api.pexels.com/v1/search', [ - 'query' => $query, - 'per_page' => $perPage, - 'orientation' => 'landscape', - ]); - - if (! $response->ok()) { - $this->warn('Pexels request failed: '.$response->status()); - - return []; - } - - $data = $response->json(); - - return Arr::get($data, 'photos', []); - } - - private function storePhotos(Event $event, array $photos): void - { - $tenantId = $event->tenant_id; - $storage = Storage::disk('public'); - $storage->makeDirectory("events/{$event->id}/gallery"); - $storage->makeDirectory("events/{$event->id}/gallery/thumbs"); - - $demoPhotos = Photo::where('event_id', $event->id) - ->where('metadata->demo', true) - ->get(); - - foreach ($demoPhotos as $photo) { - $storage->delete([$photo->file_path, $photo->thumbnail_path]); - $photo->delete(); - } - - $limit = min(count($photos), (int) $this->option('photos-per-event')); - for ($i = 0; $i < $limit; $i++) { - $photo = $photos[$i]; - $src = $photo['src'] ?? []; - $originalUrl = $src['large2x'] ?? $src['large'] ?? null; - $thumbUrl = $src['medium'] ?? $src['small'] ?? $originalUrl; - - if (! $originalUrl) { - continue; - } - - $filename = sprintf('%s-demo-%02d.jpg', $event->slug, $i + 1); - $thumbFilename = sprintf('%s-demo-%02d_thumb.jpg', $event->slug, $i + 1); - - $filePath = "events/{$event->id}/gallery/{$filename}"; - $thumbPath = "events/{$event->id}/gallery/thumbs/{$thumbFilename}"; - - try { - $imageResponse = Http::get($originalUrl); - if ($imageResponse->ok()) { - $storage->put($filePath, $imageResponse->body()); - } - - if ($thumbUrl) { - $thumbResponse = Http::get($thumbUrl); - if ($thumbResponse->ok()) { - $storage->put($thumbPath, $thumbResponse->body()); - } - } - } catch (\Throwable $exception) { - $this->warn('Failed to download image: '.$exception->getMessage()); - - continue; - } - - $timestamp = Carbon::parse($event->date ?? Carbon::now())->addHours($i); - - Photo::updateOrCreate( - [ - 'tenant_id' => $tenantId, - 'event_id' => $event->id, - 'file_path' => $filePath, - ], - [ - 'thumbnail_path' => $thumbPath, - 'guest_name' => 'Demo Guest '.($i + 1), - 'likes_count' => rand(1, 25), - 'is_featured' => $i === 0, - 'metadata' => ['demo' => true, 'source' => 'pexels'], - 'created_at' => $timestamp, - 'updated_at' => $timestamp, - ] - ); - } - - EventPackage::where('event_id', $event->id)->update([ - 'used_photos' => max($limit, 0), - 'used_guests' => max(15, $event->eventPackage?->used_guests ?? 0), - ]); - - $this->info("Seeded {$limit} photos for {$event->slug}"); - } -} diff --git a/app/Events/Packages/TenantCreditsLow.php b/app/Events/Packages/TenantCreditsLow.php deleted file mode 100644 index 0b5f64d..0000000 --- a/app/Events/Packages/TenantCreditsLow.php +++ /dev/null @@ -1,19 +0,0 @@ - 'Agency Subscription', ]) ->required(), - TextInput::make('credits_added') - ->label('Credits hinzugefügt') - ->numeric() - ->required() - ->minValue(0), TextInput::make('price') ->label('Preis') ->numeric() @@ -111,10 +106,6 @@ class EventPurchaseResource extends Resource 'monthly_pro' => 'warning', default => 'gray', }), - TextColumn::make('credits_added') - ->label('Credits') - ->badge() - ->color('success'), TextColumn::make('price') ->label('Preis') ->money('EUR') @@ -183,7 +174,6 @@ class EventPurchaseResource extends Resource ->visible(fn (EventPurchase $record): bool => $record->transaction_id && is_null($record->refunded_at)) ->action(function (EventPurchase $record) { $record->update(['refunded_at' => now()]); - $record->tenant->decrement('event_credits_balance', $record->credits_added); Log::info('Refund processed for purchase ID: ' . $record->id); }), ]) diff --git a/app/Filament/Resources/PurchaseHistoryResource.php b/app/Filament/Resources/PurchaseHistoryResource.php index 2655107..c9f2926 100644 --- a/app/Filament/Resources/PurchaseHistoryResource.php +++ b/app/Filament/Resources/PurchaseHistoryResource.php @@ -43,10 +43,6 @@ class PurchaseHistoryResource extends Resource ->label(__('admin.purchase_history.fields.package')) ->required() ->maxLength(255), - Forms\Components\TextInput::make('credits_added') - ->label(__('admin.purchase_history.fields.credits')) - ->numeric() - ->required(), Forms\Components\TextInput::make('price') ->label(__('admin.purchase_history.fields.price')) ->numeric() @@ -81,11 +77,6 @@ class PurchaseHistoryResource extends Resource ->badge() ->sortable() ->searchable(), - Tables\Columns\TextColumn::make('credits_added') - ->label(__('admin.purchase_history.fields.credits')) - ->badge() - ->color(fn (int $state): string => $state > 0 ? 'success' : ($state < 0 ? 'danger' : 'gray')) - ->sortable(), Tables\Columns\TextColumn::make('price') ->label(__('admin.purchase_history.fields.price')) ->formatStateUsing(fn ($state, PurchaseHistory $record): string => number_format((float) $state, 2).' '.($record->currency ?? 'EUR')) diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index a03befa..4d35a61 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -56,10 +56,6 @@ class TenantResource extends Resource ->email() ->required() ->maxLength(255), - TextInput::make('event_credits_balance') - ->label(__('admin.tenants.fields.event_credits_balance')) - ->numeric() - ->readOnly(), TextInput::make('paddle_customer_id') ->label('Paddle Customer ID') ->maxLength(191) @@ -112,10 +108,6 @@ class TenantResource extends Resource ->label('Paddle Customer') ->toggleable(isToggledHiddenByDefault: true) ->formatStateUsing(fn ($state) => $state ?: '-'), - Tables\Columns\TextColumn::make('event_credits_balance') - ->label(__('admin.tenants.fields.event_credits_balance')) - ->badge() - ->color(fn (int $state): string => $state <= 0 ? 'danger' : ($state < 5 ? 'warning' : 'success')), Tables\Columns\TextColumn::make('active_reseller_package_id') ->label(__('admin.tenants.fields.active_package')) ->badge() @@ -177,44 +169,6 @@ class TenantResource extends Resource 'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'], ]); }), - Actions\Action::make('adjust_credits') - ->label(__('admin.tenants.actions.adjust_credits')) - ->icon('heroicon-o-banknotes') - ->authorize(fn (Tenant $record): bool => auth()->user()?->can('adjustCredits', $record) ?? false) - ->form([ - Forms\Components\TextInput::make('delta') - ->label(__('admin.tenants.actions.adjust_credits_delta')) - ->numeric() - ->required() - ->rule('integer') - ->helperText(__('admin.tenants.actions.adjust_credits_delta_hint')), - Forms\Components\Textarea::make('reason') - ->label(__('admin.tenants.actions.adjust_credits_reason')) - ->rows(3) - ->maxLength(500), - ]) - ->action(function (Tenant $record, array $data): void { - $delta = (int) ($data['delta'] ?? 0); - - if ($delta === 0) { - return; - } - - $newBalance = max(0, $record->event_credits_balance + $delta); - - $record->forceFill([ - 'event_credits_balance' => $newBalance, - ])->save(); - - Notification::make() - ->title(__('admin.tenants.actions.adjust_credits_success_title')) - ->body(__('admin.tenants.actions.adjust_credits_success_body', [ - 'delta' => $delta, - 'balance' => $newBalance, - ])) - ->success() - ->send(); - }), Actions\Action::make('suspend') ->label('Suspendieren') ->color('danger') diff --git a/app/Filament/Widgets/CreditAlertsWidget.php b/app/Filament/Widgets/CreditAlertsWidget.php deleted file mode 100644 index bf16615..0000000 --- a/app/Filament/Widgets/CreditAlertsWidget.php +++ /dev/null @@ -1,60 +0,0 @@ -where('is_active', true) - ->where('event_credits_balance', '<', 5) - ->count(); - - $monthStart = now()->startOfMonth(); - $monthlyRevenue = PurchaseHistory::query() - ->where('purchased_at', '>=', $monthStart) - ->sum('price'); - - $activeSubscriptions = Tenant::query() - ->whereNotNull('subscription_expires_at') - ->where('subscription_expires_at', '>', now()) - ->count(); - - return [ - Stat::make( - __('admin.widgets.credit_alerts.low_balance_label'), - $lowBalanceCount - ) - ->description(__('admin.widgets.credit_alerts.low_balance_desc')) - ->descriptionIcon('heroicon-m-exclamation-triangle') - ->color('warning') - ->url(route('filament.superadmin.resources.tenants.index')), - Stat::make( - __('admin.widgets.credit_alerts.monthly_revenue_label'), - number_format((float) $monthlyRevenue, 2).' €' - ) - ->description(__('admin.widgets.credit_alerts.monthly_revenue_desc', [ - 'month' => $monthStart->translatedFormat('F'), - ])) - ->descriptionIcon('heroicon-m-currency-euro') - ->color('success'), - Stat::make( - __('admin.widgets.credit_alerts.active_subscriptions_label'), - $activeSubscriptions - ) - ->description(__('admin.widgets.credit_alerts.active_subscriptions_desc')) - ->descriptionIcon('heroicon-m-arrow-trending-up') - ->color('info'), - ]; - } -} diff --git a/app/Filament/Widgets/TopTenantsByRevenue.php b/app/Filament/Widgets/TopTenantsByRevenue.php index 6221bd1..bbcca63 100644 --- a/app/Filament/Widgets/TopTenantsByRevenue.php +++ b/app/Filament/Widgets/TopTenantsByRevenue.php @@ -42,12 +42,7 @@ class TopTenantsByRevenue extends BaseWidget ->label(__('admin.widgets.top_tenants_by_revenue.count')) ->badge() ->sortable(), - Tables\Columns\TextColumn::make('event_credits_balance') - ->label(__('admin.common.credits')) - ->badge() - ->sortable(), ]) ->paginated(false); } } - diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 6a87daf..c03b7f5 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -180,12 +180,10 @@ class EventController extends Controller 'gallery_expires_at' => $package->gallery_days ? now()->addDays($package->gallery_days) : null, ]); - if ($package->isReseller()) { - $note = sprintf('Event #%d created (%s)', $event->id, $event->name); + $note = sprintf('Event #%d created (%s)', $event->id, $event->name); - if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { - throw new HttpException(402, 'Insufficient credits or package allowance.'); - } + if (! $tenant->consumeEventAllowance(1, 'event.create', $note)) { + throw new HttpException(402, 'Insufficient package allowance.'); } return $event; @@ -220,7 +218,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'), 'eventPackages' => fn ($query) => $query ->with(['package', 'addons']) ->orderByDesc('purchased_at') diff --git a/app/Http/Controllers/Api/Tenant/SettingsController.php b/app/Http/Controllers/Api/Tenant/SettingsController.php index ba6ebcf..1a1c534 100644 --- a/app/Http/Controllers/Api/Tenant/SettingsController.php +++ b/app/Http/Controllers/Api/Tenant/SettingsController.php @@ -49,10 +49,6 @@ class SettingsController extends Controller 'defaults' => $defaults, 'preferences' => $resolved, 'overrides' => $tenant->notification_preferences ?? null, - 'meta' => [ - 'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(), - 'credit_warning_threshold' => $tenant->credit_warning_threshold, - ], ], ]); } @@ -80,10 +76,6 @@ class SettingsController extends Controller 'data' => [ 'preferences' => $resolved, 'overrides' => $tenant->notification_preferences, - 'meta' => [ - 'credit_warning_sent_at' => $tenant->credit_warning_sent_at?->toIso8601String(), - 'credit_warning_threshold' => $tenant->credit_warning_threshold, - ], ], ]); } diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php index 3bf946f..6978af4 100644 --- a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php +++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php @@ -89,7 +89,6 @@ class TenantAdminTokenController extends Controller 'tenant_id' => $tenant->id, 'name' => $tenant->name, 'slug' => $tenant->slug, - 'event_credits_balance' => $tenant->event_credits_balance, 'features' => $tenant->features, ]; } @@ -141,7 +140,6 @@ class TenantAdminTokenController extends Controller 'slug' => $tenant->slug, 'email' => $tenant->contact_email, 'fullName' => $fullName, - 'event_credits_balance' => $tenant->event_credits_balance, 'active_reseller_package_id' => $activePackage?->id, 'remaining_events' => $activePackage?->remaining_events ?? 0, 'package_expires_at' => $activePackage?->expires_at, diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 148d25d..292d854 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -77,7 +77,6 @@ class RegisteredUserController extends Controller 'email' => $request->email, 'is_active' => true, 'is_suspended' => false, - 'event_credits_balance' => 0, 'subscription_tier' => 'free', 'subscription_expires_at' => null, 'settings' => json_encode([ diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 286c2f6..5b7d4bf 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -100,7 +100,6 @@ class CheckoutController extends Controller 'email' => $validated['email'], 'is_active' => true, 'is_suspended' => false, - 'event_credits_balance' => 0, 'subscription_tier' => 'free', 'subscription_expires_at' => null, 'settings' => json_encode([ diff --git a/app/Http/Controllers/CheckoutGoogleController.php b/app/Http/Controllers/CheckoutGoogleController.php index 44ede03..bb67766 100644 --- a/app/Http/Controllers/CheckoutGoogleController.php +++ b/app/Http/Controllers/CheckoutGoogleController.php @@ -137,7 +137,6 @@ class CheckoutGoogleController extends Controller 'contact_email' => $email, 'is_active' => true, 'is_suspended' => false, - 'event_credits_balance' => 0, 'subscription_tier' => 'free', 'subscription_status' => 'free', 'subscription_expires_at' => null, diff --git a/app/Http/Controllers/Tenant/CreditController.php b/app/Http/Controllers/Tenant/CreditController.php deleted file mode 100644 index c87927d..0000000 --- a/app/Http/Controllers/Tenant/CreditController.php +++ /dev/null @@ -1,136 +0,0 @@ -resolveTenant($request); - - return response()->json([ - 'balance' => $tenant->event_credits_balance, - 'free_event_granted_at' => $tenant->free_event_granted_at, - ]); - } - - public function ledger(Request $request) - { - $tenant = $this->resolveTenant($request); - - $ledgers = EventCreditsLedger::where('tenant_id', $tenant->id) - ->orderByDesc('created_at') - ->paginate(20); - - return CreditLedgerResource::collection($ledgers); - } - - public function history(Request $request) - { - $tenant = $this->resolveTenant($request); - - $purchases = EventPurchase::where('tenant_id', $tenant->id) - ->orderByDesc('purchased_at') - ->paginate(20); - - return EventPurchaseResource::collection($purchases); - } - - public function purchase(Request $request): JsonResponse - { - $tenant = $this->resolveTenant($request); - - $data = $request->validate([ - 'package_id' => ['required', 'string', 'max:255'], - 'credits_added' => ['required', 'integer', 'min:1'], - 'platform' => ['nullable', 'string', 'max:32'], - 'transaction_id' => ['nullable', 'string', 'max:255'], - 'subscription_active' => ['sometimes', 'boolean'], - ]); - - $purchase = EventPurchase::create([ - 'tenant_id' => $tenant->id, - 'events_purchased' => $data['credits_added'], - 'amount' => 0, - 'currency' => 'EUR', - 'provider' => $data['platform'] ?? 'tenant-app', - 'external_receipt_id' => $data['transaction_id'] ?? null, - 'status' => 'completed', - 'purchased_at' => now(), - ]); - - $note = 'Package: '.$data['package_id']; - $incremented = $tenant->incrementCredits($data['credits_added'], 'purchase', $note, $purchase->id); - - if (! $incremented) { - throw new HttpException(500, 'Unable to record credit purchase'); - } - - if (array_key_exists('subscription_active', $data)) { - $tenant->update([ - 'subscription_tier' => $data['subscription_active'] ? 'pro' : $tenant->subscription_tier, - ]); - } - - $tenant->refresh(); - - return response()->json([ - 'message' => 'Purchase recorded', - 'balance' => $tenant->event_credits_balance, - 'subscription_active' => (bool) ($data['subscription_active'] ?? false), - ], 201); - } - - public function sync(Request $request): JsonResponse - { - $tenant = $this->resolveTenant($request); - - $data = $request->validate([ - 'balance' => ['nullable', 'integer', 'min:0'], - 'subscription_active' => ['sometimes', 'boolean'], - 'last_sync' => ['nullable', 'date'], - ]); - - if (array_key_exists('subscription_active', $data)) { - $tenant->update([ - 'subscription_tier' => $data['subscription_active'] ? 'pro' : ($tenant->subscription_tier ?? 'free'), - ]); - } - - // Server remains source of truth for balance; echo current state back to client - return response()->json([ - 'balance' => $tenant->event_credits_balance, - 'subscription_active' => (bool) ($tenant->active_subscription ?? false), - 'server_time' => now()->toIso8601String(), - ]); - } - - private function resolveTenant(Request $request): Tenant - { - $user = $request->user(); - if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) { - return $user->tenant; - } - - $tenantId = $request->attributes->get('tenant_id'); - if (! $tenantId && $user && isset($user->tenant_id)) { - $tenantId = $user->tenant_id; - } - - if (! $tenantId) { - throw new HttpException(401, 'Unauthenticated'); - } - - return Tenant::findOrFail($tenantId); - } -} diff --git a/app/Jobs/Packages/SendTenantCreditsLowNotification.php b/app/Jobs/Packages/SendTenantCreditsLowNotification.php deleted file mode 100644 index d490dca..0000000 --- a/app/Jobs/Packages/SendTenantCreditsLowNotification.php +++ /dev/null @@ -1,100 +0,0 @@ -tenantId); - - if (! $tenant) { - Log::warning('Tenant credits low job skipped; tenant missing', [ - 'tenant_id' => $this->tenantId, - ]); - - return; - } - - $preferences = app(\App\Services\Packages\TenantNotificationPreferences::class); - if (! $preferences->shouldNotify($tenant, 'credits_low')) { - $this->logNotification($tenant, [ - 'type' => 'credits_low', - 'status' => 'skipped', - 'context' => [ - 'balance' => $this->balance, - 'threshold' => $this->threshold, - 'reason' => 'opt_out', - ], - ]); - - return; - } - - $emails = collect([ - $tenant->contact_email, - $tenant->user?->email, - ])->filter(fn ($email) => is_string($email) && filter_var($email, FILTER_VALIDATE_EMAIL)) - ->unique(); - - if ($emails->isEmpty()) { - Log::info('Tenant credits low notification skipped due to missing recipients', [ - 'tenant_id' => $tenant->id, - ]); - - $this->logNotification($tenant, [ - 'type' => 'credits_low', - 'status' => 'skipped', - 'context' => [ - 'balance' => $this->balance, - 'threshold' => $this->threshold, - 'reason' => 'no_recipient', - ], - ]); - - return; - } - - $context = [ - 'balance' => $this->balance, - 'threshold' => $this->threshold, - ]; - - $this->dispatchToRecipients( - $tenant, - $emails, - 'credits_low', - function (string $email) use ($tenant) { - Notification::route('mail', $email)->notify(new TenantCreditsLowNotification( - $tenant, - $this->balance, - $this->threshold, - )); - }, - $context - ); - } -} diff --git a/app/Jobs/ProcessRevenueCatWebhook.php b/app/Jobs/ProcessRevenueCatWebhook.php index 4f80f47..22228a9 100644 --- a/app/Jobs/ProcessRevenueCatWebhook.php +++ b/app/Jobs/ProcessRevenueCatWebhook.php @@ -66,14 +66,6 @@ class ProcessRevenueCatWebhook implements ShouldQueue ?? $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 @@ -101,9 +93,6 @@ class ProcessRevenueCatWebhook implements ShouldQueue 'status' => 'completed', 'purchased_at' => now(), ]); - - $note = sprintf('RevenueCat product: %s', $productId ?? 'unknown'); - $tenant->incrementCredits($credits, 'purchase', $note, $purchase->id); }); $tenant->refresh(); diff --git a/app/Jobs/ValidateStripeWebhookJob.php b/app/Jobs/ValidateStripeWebhookJob.php index 62a9f6a..8fa3151 100644 --- a/app/Jobs/ValidateStripeWebhookJob.php +++ b/app/Jobs/ValidateStripeWebhookJob.php @@ -88,7 +88,6 @@ class ValidateStripeWebhookJob implements ShouldQueue 'purchased_at' => now(), ]); - $tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id); }); Log::info('Processed Stripe purchase via job', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]); @@ -96,4 +95,4 @@ class ValidateStripeWebhookJob implements ShouldQueue Log::info('Unhandled Stripe event in job', ['type' => $event['type']]); } } -} \ No newline at end of file +} diff --git a/app/Listeners/Packages/QueueTenantCreditsLowNotification.php b/app/Listeners/Packages/QueueTenantCreditsLowNotification.php deleted file mode 100644 index 4cc0f6a..0000000 --- a/app/Listeners/Packages/QueueTenantCreditsLowNotification.php +++ /dev/null @@ -1,18 +0,0 @@ -tenant->id, - $event->balance, - $event->threshold - ); - } -} diff --git a/app/Models/EventCreditsLedger.php b/app/Models/EventCreditsLedger.php deleted file mode 100644 index 381bbf9..0000000 --- a/app/Models/EventCreditsLedger.php +++ /dev/null @@ -1,29 +0,0 @@ - 'datetime', - 'delta' => 'integer', - ]; - - public function tenant(): BelongsTo - { - return $this->belongsTo(Tenant::class); - } - - public function relatedPurchase(): BelongsTo - { - return $this->belongsTo(EventPurchase::class, 'related_purchase_id'); - } -} \ No newline at end of file diff --git a/app/Models/PurchaseHistory.php b/app/Models/PurchaseHistory.php index e5ee23c..df67353 100644 --- a/app/Models/PurchaseHistory.php +++ b/app/Models/PurchaseHistory.php @@ -18,7 +18,6 @@ class PurchaseHistory extends Model protected $guarded = []; protected $casts = [ - 'credits_added' => 'integer', 'price' => 'decimal:2', 'purchased_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 48d5d8b..d7212e2 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -109,84 +109,6 @@ class Tenant extends Model $this->attributes['settings'] = json_encode($value ?? []); } - public function incrementCredits(int $amount, string $reason = 'manual', ?string $note = null, ?int $purchaseId = null): bool - { - if ($amount <= 0) { - return false; - } - - $balance = (int) ($this->event_credits_balance ?? 0) + $amount; - $this->forceFill(['event_credits_balance' => $balance])->save(); - - $maxThreshold = collect(config('package-limits.credit_thresholds', [])) - ->filter(fn ($value) => is_numeric($value) && $value >= 0) - ->map(fn ($value) => (int) $value) - ->max(); - - if ( - $maxThreshold !== null - && $balance > $maxThreshold - && ($this->credit_warning_sent_at !== null || $this->credit_warning_threshold !== null) - ) { - $this->forceFill([ - 'credit_warning_sent_at' => null, - 'credit_warning_threshold' => null, - ])->save(); - } - - EventCreditsLedger::create([ - 'tenant_id' => $this->id, - 'delta' => $amount, - 'reason' => $reason, - 'related_purchase_id' => $purchaseId, - 'note' => $note, - ]); - - Log::info('Tenant credits incremented', [ - 'tenant_id' => $this->id, - 'delta' => $amount, - 'reason' => $reason, - 'purchase_id' => $purchaseId, - ]); - - return true; - } - - public function decrementCredits(int $amount, string $reason = 'usage', ?string $note = null, ?int $purchaseId = null): bool - { - $current = (int) ($this->event_credits_balance ?? 0); - - if ($amount <= 0 || $amount > $current) { - return false; - } - - $balance = $current - $amount; - $this->forceFill(['event_credits_balance' => $balance])->save(); - - app(\App\Services\Packages\TenantUsageTracker::class)->recordCreditBalance( - $this, - $current, - $balance - ); - - EventCreditsLedger::create([ - 'tenant_id' => $this->id, - 'delta' => -$amount, - 'reason' => $reason, - 'related_purchase_id' => $purchaseId, - 'note' => $note, - ]); - - Log::info('Tenant credits decremented', [ - 'tenant_id' => $this->id, - 'delta' => -$amount, - 'reason' => $reason, - 'purchase_id' => $purchaseId, - ]); - - return true; - } - public function hasEventAllowance(): bool { $package = $this->getActiveResellerPackage(); @@ -194,7 +116,7 @@ class Tenant extends Model return true; } - return (int) ($this->event_credits_balance ?? 0) > 0; + return false; } public function consumeEventAllowance(int $amount = 1, string $reason = 'event.create', ?string $note = null): bool @@ -221,7 +143,12 @@ class Tenant extends Model return true; } - return $this->decrementCredits($amount, $reason, $note); + Log::warning('Event allowance missing for tenant', [ + 'tenant_id' => $this->id, + 'reason' => $reason, + ]); + + return false; } public function getActiveResellerPackage(): ?TenantPackage diff --git a/app/Notifications/Packages/TenantCreditsLowNotification.php b/app/Notifications/Packages/TenantCreditsLowNotification.php deleted file mode 100644 index a83d79b..0000000 --- a/app/Notifications/Packages/TenantCreditsLowNotification.php +++ /dev/null @@ -1,42 +0,0 @@ -subject(__('emails.package_limits.credits_low.subject')) - ->greeting(__('emails.package_limits.credits_low.greeting', [ - 'name' => $this->tenant->name ?? __('emails.package_limits.team_fallback'), - ])) - ->line(__('emails.package_limits.credits_low.body', [ - 'balance' => $this->balance, - 'threshold' => $this->threshold, - ])) - ->action(__('emails.package_limits.credits_low.action'), $url) - ->line(__('emails.package_limits.footer')); - } -} diff --git a/app/Policies/TenantPolicy.php b/app/Policies/TenantPolicy.php index 58ccdc5..5bb1c1a 100644 --- a/app/Policies/TenantPolicy.php +++ b/app/Policies/TenantPolicy.php @@ -54,14 +54,6 @@ class TenantPolicy return $user->role === 'super_admin'; } - /** - * Custom ability for adjusting credits. - */ - public function adjustCredits(User $user, Tenant $tenant): bool - { - return $user->role === 'super_admin'; - } - /** * Custom ability for suspending a tenant. */ @@ -70,4 +62,3 @@ class TenantPolicy return $user->role === 'super_admin'; } } - diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fe61173..f402849 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,7 +10,6 @@ use App\Events\Packages\EventPackageGuestLimitReached; use App\Events\Packages\EventPackageGuestThresholdReached; use App\Events\Packages\EventPackagePhotoLimitReached; use App\Events\Packages\EventPackagePhotoThresholdReached; -use App\Events\Packages\TenantCreditsLow; use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventThresholdReached; use App\Events\Packages\TenantPackageExpired; @@ -23,7 +22,6 @@ use App\Listeners\Packages\QueueGuestLimitNotification; use App\Listeners\Packages\QueueGuestThresholdNotification; use App\Listeners\Packages\QueuePhotoLimitNotification; use App\Listeners\Packages\QueuePhotoThresholdNotification; -use App\Listeners\Packages\QueueTenantCreditsLowNotification; use App\Listeners\Packages\QueueTenantEventLimitNotification; use App\Listeners\Packages\QueueTenantEventThresholdNotification; use App\Listeners\Packages\QueueTenantPackageExpiredNotification; @@ -133,11 +131,6 @@ class AppServiceProvider extends ServiceProvider [QueueTenantPackageExpiredNotification::class, 'handle'] ); - EventFacade::listen( - TenantCreditsLow::class, - [QueueTenantCreditsLowNotification::class, 'handle'] - ); - EventFacade::listen( GuestPhotoUploaded::class, [SendPhotoUploadedNotification::class, 'handle'] diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index ae42dc1..5058262 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -6,7 +6,6 @@ use App\Filament\Blog\Resources\CategoryResource; use App\Filament\Blog\Resources\PostResource; use App\Filament\Resources\InfrastructureActionLogs\InfrastructureActionLogResource; use App\Filament\Resources\LegalPageResource; -use App\Filament\Widgets\CreditAlertsWidget; use App\Filament\Widgets\DokployPlatformHealth; use App\Filament\Widgets\PlatformStatsWidget; use App\Filament\Widgets\RevenueTrendWidget; @@ -54,7 +53,6 @@ class SuperAdminPanelProvider extends PanelProvider ->widgets([ Widgets\AccountWidget::class, Widgets\FilamentInfoWidget::class, - CreditAlertsWidget::class, RevenueTrendWidget::class, PlatformStatsWidget::class, \App\Filament\Widgets\CouponUsageWidget::class, diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 70986e5..7c0887d 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -142,7 +142,6 @@ class CheckoutAssignmentService 'email' => $user->email, 'is_active' => true, 'is_suspended' => false, - 'event_credits_balance' => 0, 'subscription_tier' => 'free', 'subscription_status' => 'active', 'settings' => [ diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 1d30942..8e728a1 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -16,33 +16,37 @@ class PackageLimitEvaluator $package = $tenant->getActiveResellerPackage(); - if ($package) { - $limit = $package->package->max_events_per_year ?? 0; - + if (! $package) { return [ - 'code' => 'event_limit_exceeded', - 'title' => 'Event quota reached', - 'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', + 'code' => 'event_limit_missing', + 'title' => 'No package assigned', + 'message' => 'Assign a package or addon to create events.', 'status' => 402, 'meta' => [ 'scope' => 'events', - 'used' => (int) $package->used_events, - 'limit' => $limit, - 'remaining' => max(0, $limit - $package->used_events), - 'tenant_package_id' => $package->id, - 'package_id' => $package->package_id, + 'used' => 0, + 'limit' => 0, + 'remaining' => 0, + 'tenant_package_id' => null, + 'package_id' => null, ], ]; } + $limit = $package->package->max_events_per_year ?? 0; + return [ - 'code' => 'event_credits_exhausted', - 'title' => 'No event credits remaining', - 'message' => 'You have no event credits remaining. Purchase additional credits or a package to create new events.', + 'code' => 'event_limit_exceeded', + 'title' => 'Event quota reached', + 'message' => 'Your current package has no remaining event slots. Please upgrade or renew your subscription.', 'status' => 402, 'meta' => [ - 'scope' => 'credits', - 'balance' => (int) ($tenant->event_credits_balance ?? 0), + 'scope' => 'events', + 'used' => (int) $package->used_events, + 'limit' => $limit, + 'remaining' => max(0, $limit - $package->used_events), + 'tenant_package_id' => $package->id, + 'package_id' => $package->package_id, ], ]; } diff --git a/app/Services/Packages/TenantNotificationPreferences.php b/app/Services/Packages/TenantNotificationPreferences.php index 8c50b77..71e98ff 100644 --- a/app/Services/Packages/TenantNotificationPreferences.php +++ b/app/Services/Packages/TenantNotificationPreferences.php @@ -17,7 +17,6 @@ class TenantNotificationPreferences 'event_limits' => true, 'package_expiring' => true, 'package_expired' => true, - 'credits_low' => true, ]; public static function defaults(): array diff --git a/app/Services/Packages/TenantUsageTracker.php b/app/Services/Packages/TenantUsageTracker.php index 2a2fb6c..21dffc8 100644 --- a/app/Services/Packages/TenantUsageTracker.php +++ b/app/Services/Packages/TenantUsageTracker.php @@ -2,7 +2,6 @@ namespace App\Services\Packages; -use App\Events\Packages\TenantCreditsLow; use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventThresholdReached; use App\Models\Tenant; @@ -66,32 +65,4 @@ class TenantUsageTracker $this->dispatcher->dispatch(new TenantPackageEventLimitReached($tenantPackage, $limit)); } } - - public function recordCreditBalance(Tenant $tenant, int $previousBalance, int $newBalance): void - { - $thresholds = collect(config('package-limits.credit_thresholds', [])) - ->filter(fn ($value) => is_numeric($value) && $value >= 0) - ->map(fn ($value) => (int) $value) - ->sortDesc() - ->values(); - - $currentThreshold = $tenant->credit_warning_threshold ?? null; - - foreach ($thresholds as $threshold) { - if ($previousBalance > $threshold && $newBalance <= $threshold) { - if ($currentThreshold !== null && $threshold >= $currentThreshold) { - continue; - } - - $tenant->forceFill([ - 'credit_warning_sent_at' => now(), - 'credit_warning_threshold' => $threshold, - ])->save(); - - $this->dispatcher->dispatch(new TenantCreditsLow($tenant, $newBalance, $threshold)); - - break; - } - } - } } diff --git a/database/factories/PurchaseHistoryFactory.php b/database/factories/PurchaseHistoryFactory.php index 9a4ddf6..b260427 100644 --- a/database/factories/PurchaseHistoryFactory.php +++ b/database/factories/PurchaseHistoryFactory.php @@ -17,7 +17,6 @@ class PurchaseHistoryFactory extends Factory 'id' => (string) Str::uuid(), 'tenant_id' => Tenant::factory(), 'package_id' => $this->faker->randomElement(['starter', 'pro', 'enterprise']), - 'credits_added' => $this->faker->numberBetween(1, 10), 'price' => $this->faker->randomFloat(2, 9, 199), 'currency' => 'EUR', 'platform' => $this->faker->randomElement(['tenant-app', 'ios', 'android']), diff --git a/database/factories/TenantFactory.php b/database/factories/TenantFactory.php index 4edb506..ff30730 100644 --- a/database/factories/TenantFactory.php +++ b/database/factories/TenantFactory.php @@ -20,7 +20,6 @@ class TenantFactory extends Factory 'name' => $name, 'slug' => $slug, 'contact_email' => $contactEmail, - 'event_credits_balance' => $this->faker->numberBetween(1, 20), 'subscription_tier' => $this->faker->randomElement(['free', 'starter', 'pro']), 'subscription_expires_at' => $this->faker->dateTimeBetween('now', '+1 year'), 'is_active' => true, @@ -62,9 +61,6 @@ class TenantFactory extends Factory public function withLowCredits(): static { - return $this->state(fn (array $attributes) => [ - 'event_credits_balance' => 1, - ]); + return $this->state(fn (array $attributes) => $attributes); } } - diff --git a/database/migrations/2025_09_01_000100_create_tenancy_system.php b/database/migrations/2025_09_01_000100_create_tenancy_system.php index 19eb7d4..11824b7 100644 --- a/database/migrations/2025_09_01_000100_create_tenancy_system.php +++ b/database/migrations/2025_09_01_000100_create_tenancy_system.php @@ -18,8 +18,6 @@ return new class extends Migration $table->string('contact_name')->nullable(); $table->string('contact_email')->nullable(); $table->string('contact_phone')->nullable(); - $table->integer('event_credits_balance')->default(1); - $table->timestamp('free_event_granted_at')->nullable(); $table->integer('max_photos_per_event')->default(500); $table->integer('max_storage_mb')->default(1024); $table->json('features')->nullable(); @@ -39,6 +37,11 @@ return new class extends Migration $table->string('email')->nullable()->after('contact_phone'); }); } + if (Schema::hasColumn('tenants', 'event_credits_balance')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn(['event_credits_balance', 'free_event_granted_at']); + }); + } if (!Schema::hasColumn('tenants', 'stripe_account_id')) { Schema::table('tenants', function (Blueprint $table) { $table->string('stripe_account_id')->nullable()->after('features'); @@ -106,4 +109,4 @@ return new class extends Migration Schema::dropIfExists('tenants'); } } -}; \ No newline at end of file +}; diff --git a/database/migrations/2025_09_26_000000_create_packages_system.php b/database/migrations/2025_09_26_000000_create_packages_system.php index 8d6b338..847216b 100644 --- a/database/migrations/2025_09_26_000000_create_packages_system.php +++ b/database/migrations/2025_09_26_000000_create_packages_system.php @@ -141,7 +141,6 @@ return new class extends Migration $table->bigIncrements('id'); $table->foreignId('tenant_id')->constrained('tenants'); $table->unsignedBigInteger('package_id'); - $table->integer('credits_added')->default(0); $table->decimal('price', 10, 2)->default(0); $table->string('currency', 3)->default('EUR'); $table->string('platform', 50); @@ -158,7 +157,7 @@ return new class extends Migration if (Schema::hasTable('tenants')) { if (! Schema::hasColumn('tenants', 'subscription_tier')) { Schema::table('tenants', function (Blueprint $table) { - $table->string('subscription_tier')->default('free')->after('event_credits_balance'); + $table->string('subscription_tier')->default('free')->after('email'); }); } if (! Schema::hasColumn('tenants', 'subscription_status')) { @@ -178,82 +177,8 @@ return new class extends Migration } } - // Idempotent migration from credits to packages (only if old tables exist and new don't have data) - if (Schema::hasTable('event_credits_ledger') && DB::table('tenant_packages')->count() == 0) { - // Migrate tenant credits to tenant_packages (Free package) - $freePackageId = DB::table('packages')->where('name', 'Free/Test')->value('id'); - if ($freePackageId) { - DB::table('tenants')->where('event_credits_balance', '>', 0)->chunk(100, function ($tenants) use ($freePackageId) { - foreach ($tenants as $tenant) { - DB::table('tenant_packages')->insertOrIgnore([ - 'tenant_id' => $tenant->id, - 'package_id' => $freePackageId, - 'price' => 0.00, - 'purchased_at' => $tenant->free_event_granted_at ?? now(), - 'expires_at' => now()->addDays(30), - 'used_events' => min($tenant->event_credits_balance, 1), - 'active' => true, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::table('package_purchases')->insertOrIgnore([ - 'tenant_id' => $tenant->id, - 'event_id' => null, - 'package_id' => $freePackageId, - 'provider_id' => 'migration_free', - 'price' => 0.00, - 'type' => 'reseller_subscription', - 'metadata' => json_encode(['migrated_from_credits' => $tenant->event_credits_balance]), - 'ip_address' => null, - 'user_agent' => null, - 'refunded' => false, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - }); - - // Migrate event purchases if old data exists - if (Schema::hasTable('event_purchases')) { - DB::table('event_purchases')->join('events', 'event_purchases.event_id', '=', 'events.id')->chunk(100, function ($purchases) use ($freePackageId) { - foreach ($purchases as $purchase) { - DB::table('event_packages')->insertOrIgnore([ - 'event_id' => $purchase->event_id, - 'package_id' => $freePackageId, - 'purchased_price' => $purchase->amount ?? 0.00, - 'purchased_at' => $purchase->purchased_at ?? now(), - 'used_photos' => 0, - 'created_at' => now(), - 'updated_at' => now(), - ]); - - DB::table('package_purchases')->insertOrIgnore([ - 'tenant_id' => $purchase->tenant_id, - 'event_id' => $purchase->event_id, - 'package_id' => $freePackageId, - 'provider_id' => $purchase->provider ?? 'migration', - 'price' => $purchase->amount ?? 0.00, - 'type' => 'endcustomer_event', - 'metadata' => json_encode(['migrated_from_event_purchases' => true]), - 'ip_address' => null, - 'user_agent' => null, - 'refunded' => false, - 'created_at' => now(), - 'updated_at' => now(), - ]); - } - }); - } - } - } - - // Conditional drop of old credits tables and fields (only if migration happened or old structures exist) - if (Schema::hasTable('event_credits_ledger')) { - Schema::dropIfExists('event_credits_ledger'); - } - // Keep legacy event_purchases table for compatibility with existing flows/resources. - if (Schema::hasTable('purchase_history') && DB::table('package_purchases')->count() > 0) { // Only drop if new data exists + Schema::dropIfExists('event_credits_ledger'); + if (Schema::hasTable('purchase_history') && DB::table('package_purchases')->count() > 0) { Schema::dropIfExists('purchase_history'); } @@ -279,7 +204,6 @@ return new class extends Migration $table->id(); $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); $table->string('package_id', 255); - $table->integer('credits_added')->default(0); $table->decimal('price', 10, 2)->default(0); $table->string('provider_id'); $table->timestamps(); diff --git a/database/seeders/DemoTenantSeeder.php b/database/seeders/DemoTenantSeeder.php index be7997d..ef96ad3 100644 --- a/database/seeders/DemoTenantSeeder.php +++ b/database/seeders/DemoTenantSeeder.php @@ -70,7 +70,6 @@ class DemoTenantSeeder extends Seeder 'email' => $user->email, 'is_active' => true, 'is_suspended' => false, - 'event_credits_balance' => 0, 'subscription_tier' => $package->type, 'subscription_status' => 'active', 'settings' => [ diff --git a/database/seeders/_DemoLifecycleSeeder.php b/database/seeders/_DemoLifecycleSeeder.php index d8e2017..a26469f 100644 --- a/database/seeders/_DemoLifecycleSeeder.php +++ b/database/seeders/_DemoLifecycleSeeder.php @@ -120,7 +120,6 @@ class DemoLifecycleSeeder extends Seeder $tenant = $this->createTenant('storycraft-weddings', [ 'name' => 'Storycraft Weddings', 'contact_email' => 'hello@storycraft-weddings.demo', - 'event_credits_balance' => 0, 'subscription_tier' => 'free', 'subscription_status' => 'free', 'subscription_expires_at' => null, @@ -135,7 +134,6 @@ class DemoLifecycleSeeder extends Seeder $tenant = $this->createTenant('lumen-moments', [ 'name' => 'Lumen Moments', 'contact_email' => 'hello@lumen-moments.demo', - 'event_credits_balance' => 2, 'subscription_tier' => 'starter', 'subscription_status' => 'active', 'is_active' => true, @@ -200,7 +198,6 @@ class DemoLifecycleSeeder extends Seeder $tenant = $this->createTenant('viewfinder-studios', [ 'name' => 'Viewfinder Studios', 'contact_email' => 'team@viewfinder.demo', - 'event_credits_balance' => 0, 'subscription_tier' => 'reseller', 'subscription_status' => 'active', 'is_active' => true, @@ -272,7 +269,6 @@ class DemoLifecycleSeeder extends Seeder 'subscription_expires_at' => Carbon::now()->subMonths(2), 'is_active' => false, 'is_suspended' => false, - 'event_credits_balance' => 0, ]); $this->createTenantAdmin($tenant, 'support@pixelco.demo', role: 'member'); diff --git a/docs/archive/ADR-0006-tenant-admin-pwa.md b/docs/archive/ADR-0006-tenant-admin-pwa.md index 1be28fa..2aa1e9c 100644 --- a/docs/archive/ADR-0006-tenant-admin-pwa.md +++ b/docs/archive/ADR-0006-tenant-admin-pwa.md @@ -17,7 +17,7 @@ The original PRP envisioned tenant administration via a Filament panel. We want - iOS: Capacitor wrapper for App Store distribution. - Keep Super Admin as a Filament 4 web panel only. - Expose all tenant features through `/api/v1/tenant/*`, authenticated using Authorization Code + PKCE and refresh tokens. Tokens include `tenant_id` and roles. Enforce tenant isolation with global scopes and policies. -- MVP billing uses event credits; subscriptions are deferred. +- Billing: Packages & add-ons (legacy credits removed). ## Consequences @@ -40,4 +40,3 @@ The original PRP envisioned tenant administration via a Filament panel. We want - Keep tenant admin in Filament: faster initially but not store-distributable and poorer mobile UX. - Native apps: higher cost and longer timeline; PWA + thin wrappers meet requirements. - diff --git a/docs/archive/implementation-roadmap.md b/docs/archive/implementation-roadmap.md index 3e9d6bf..3443d66 100644 --- a/docs/archive/implementation-roadmap.md +++ b/docs/archive/implementation-roadmap.md @@ -33,8 +33,8 @@ 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)**: 60% implementiert – event_credits_balance Feld vorhanden, Endpunkt/Controller stehen, Credit-Middleware aktiv, RevenueCat Webhook inkl. Queue/Retries produktionsreif; Token-Rotation folgt. -- **Phase 4 (Admin & Monitoring)**: 45% implementiert – **TenantResource erweitert (credits, features, activeSubscription)**, PurchaseHistory/OAuthClient-Management sowie Dashboard-Widgets fertig; verbleibend sind Advanced Actions (subscription_tier) und erweiterte Monitoring-Policies. +- **Phase 3 (Business Logic)**: 60% implementiert – (Legacy Credits entfernt), Packages/Add-ons aktiv; RevenueCat Webhook inkl. Queue/Retries produktionsreif; Token-Rotation folgt. +- **Phase 4 (Admin & Monitoring)**: 45% implementiert – **TenantResource erweitert (packages, features, activeSubscription)**, PurchaseHistory/OAuthClient-Management sowie Dashboard-Widgets fertig; verbleibend sind Advanced Actions (subscription_tier) und erweiterte Monitoring-Policies. **Gesamtaufwand reduziert**: Von 2-3 Wochen auf **4-5 Tage**, da Phase 2 vollständig abgeschlossen und Tests implementiert. @@ -90,7 +90,7 @@ Basierend auf aktueller Code-Analyse und Implementierung: - Credit-Management, Webhooks, Security ### Implementierter Fortschritt -- [x] Credit-Feld in Tenant-Model mit `event_credits_balance` +- [x] Legacy credit fields removed; package usage enforced - [x] **Tenant::decrementCredits()/incrementCredits() Methoden** inkl. Logging implementiert - [x] Credit-Middleware & Route-Alias greifen vor Event-Create; `Tenant::consumeEventAllowance()` nutzt zuerst Reseller-Pakete, sonst Credits - [x] RevenueCat-Webhook: Signatur-Validierung, Queue-Konfiguration, Retry (`tries/backoff`) + Produkt-Mapping @@ -113,13 +113,13 @@ Basierend auf aktueller Code-Analyse und Implementierung: - Analytics Dashboard, Testing ### Implementierter Fortschritt -- [x] **TenantResource erweitert**: credits, features, activeSubscription Attribute +- [x] **TenantResource erweitert**: packages, features, activeSubscription Attribute - [x] **TenantModelTest**: 11 Unit-Tests für Beziehungen (events, photos, purchases), Attribute, Methoden - [x] PurchaseHistoryResource, OAuthClientResource, Widgets, Policies ### Verbleibende Tasks 1. **Filament Resources erweitern (2 Tage)** - - TenantResource: subscription_tier, Actions (add_credits, suspend), RelationsManager *(Credits-Aktion fertig; subscription_tier-Actions noch offen)* + - TenantResource: subscription_tier, Actions (packages), RelationsManager *(Credits-Aktion entfernt; subscription_tier-Actions noch offen)* - PurchaseHistoryResource: CRUD, Filter, Export, Refund *(CRUD & Export umgesetzt; Refund via UI noch offen)* - OAuthClientResource: Client-Management *(implementiert)* - TenantPolicy mit superadmin before() *(implementiert)* diff --git a/docs/archive/packages-business-model-plan.md b/docs/archive/packages-business-model-plan.md index ddb4d6c..58cf84b 100644 --- a/docs/archive/packages-business-model-plan.md +++ b/docs/archive/packages-business-model-plan.md @@ -4,29 +4,10 @@ **Version:** 1.0 **Autor:** Kilo Code (Architect Mode) **Status:** Finaler Plan für Review und Implementation in Code-Mode. -**Ziel:** Ersetze das aktuelle Credits-basierte Freemium-Modell (One-off-Käufe via Stripe/RevenueCat, Balance-Checks) durch ein package-basiertes Modell mit vordefinierten Bündeln (Einmalkäufe pro Event für Endkunden, jährliche Subscriptions für Reseller/Agenturen). Der Plan deckt Analyse, Design, Änderungen in DB/Code/UI/Billing, Lücken und Rollout ab. Alle Details basieren auf User-Feedback und Best Practices für Laravel 12, Filament 4, React/Vite PWA. +**Ziel:** Packages/Add-ons ersetzen das frühere Credits-Modell (Credits sind legacy/outdated). Der Plan deckt Analyse, Design, Änderungen in DB/Code/UI/Billing, Lücken und Rollout ab. Alle Details basieren auf User-Feedback und Best Practices für Laravel 12, Filament 4, React/Vite PWA. -## 1. Analyse des Aktuellen Modells -Das bestehende Modell ist Credits-basiert (Freemium mit 1 Free-Credit, One-off-Käufen für Events). Subscriptions sind deferred (nicht implementiert). - -### Betroffene Komponenten: -- **DB:** - - Felder: `event_credits_balance` (in `tenants`, default 1), `subscription_tier`/`subscription_expires_at` (in `tenants`). - - Tabellen: `event_purchases` (Käufe), `event_credits_ledger` (Transaktionen), `purchase_history` (IAP-Historie). -- **Code (Backend):** - - Models: `Tenant::decrementCredits()`/`incrementCredits()`. - - Controllers: `EventController` (Credit-Check bei Create), `CreditController` (Balance/Purchase). - - Middleware: `CreditMiddleware` (prüft Balance >=1 für Events). - - Filament: `TenantResource` (credits-Column, add_credits-Action), `PurchaseHistoryResource` (CRUD/Refund). -- **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`. -- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat). -- **Guest PWA:** Keine direkten Checks (Backend-handhabt). -- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PaddleWebhookController (teilweise). -- **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests. -- **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte). -- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, Paddle untergenutzt. - -**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen. +## 1. Analyse des Alt-Modells (Legacy Credits) +Früheres Credits-Modell ist deprecated. Alle neuen Flows basieren auf Packages/Add-ons. ## 2. Neues Package-basiertes Modell Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei Event-Create (Endkunden) oder Tenant-Upgrade (Reseller). Freemium: Free/Test-Paket für Einstieg. @@ -113,11 +94,8 @@ $table->timestamps(); $table->index(['tenant_id', 'purchased_at']); ``` -### Migration-Strategie (php artisan make:migration migrate_to_packages) -- **Schritt 1:** Neue Tabellen erstellen + Seeder für Standard-Packages (php artisan make:seeder PackageSeeder). -- **Schritt 2:** Daten-Transfer (Artisan-Command packages:migrate): - - Tenants: if event_credits_balance > 0 → Zuweisen zu Free-Paket (insert tenant_packages mit expires_at = now() + 30 days); alte Balance zu used_events konvertieren (z.B. balance / 100 = initial events). - - Events: Bestehende Events zu Test-Paket migrieren (insert event_packages). +### Migration-Strategie +- Credits-Migration wird nicht mehr genutzt; Bestandsdaten auf Packages/Add-ons umstellen (event_credits_* Felder/Tables droppen). - Ledger: Transfer event_purchases zu package_purchases (map credits_added zu package_id = 'free'). - **Schritt 3:** Alte Felder/Tabellen droppen (in separater Migration, nach Backup). - **Rollback:** php artisan migrate:rollback --step=3; Restore aus Backup. diff --git a/docs/archive/plan-superadmin-filament.md b/docs/archive/plan-superadmin-filament.md index d05f60b..0dbbf9b 100644 --- a/docs/archive/plan-superadmin-filament.md +++ b/docs/archive/plan-superadmin-filament.md @@ -8,7 +8,7 @@ TextInput::make('name')->required()->maxLength(255), TextInput::make('slug')->required()->unique()->maxLength(255), TextInput::make('contact_email')->email()->required()->maxLength(255), -TextInput::make('event_credits_balance')->numeric()->default(1), // Free tier +// Legacy credits removed; use packages/add-ons Select::make('subscription_tier') ->options([ 'free' => 'Free', @@ -31,7 +31,7 @@ Toggle::make('is_suspended')->label('Suspended')->default(false), Tables\Columns\TextColumn::make('name')->searchable()->sortable(), Tables\Columns\TextColumn::make('slug')->badge()->color('primary'), Tables\Columns\TextColumn::make('contact_email')->copyable(), -Tables\Columns\TextColumn::make('event_credits_balance') +// Legacy credits column removed ->label('Credits') ->badge() ->color(fn ($state) => $state < 5 ? 'warning' : 'success'), @@ -69,7 +69,7 @@ public static function table(Table $table): Table ->recordTitleAttribute('package_id') ->columns([ Tables\Columns\TextColumn::make('package_id')->badge(), - Tables\Columns\TextColumn::make('credits_added')->badge(), + // credits_added removed Tables\Columns\TextColumn::make('price')->money('EUR'), Tables\Columns\TextColumn::make('platform')->badge(), Tables\Columns\TextColumn::make('purchased_at')->dateTime(), @@ -93,11 +93,11 @@ public static function table(Table $table): Table ->actions([ Actions\ViewAction::make(), Actions\EditAction::make(), - Actions\Action::make('add_credits') + // add_credits removed (legacy) ->label('Credits hinzufügen') ->icon('heroicon-o-plus') ->form([ - Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1), + // credits input removed Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), ]) ->action(function (Tenant $record, array $data) { @@ -631,4 +631,4 @@ class SuperAdminMiddleware 6. **Policies & Middleware:** Security für SuperAdmin-Funktionen 7. **Tests:** Feature-Tests für Credit-Management, Permissions -Dieser Plan erweitert den SuperAdmin-Bereich um umfassende Billing- und Security-Management-Funktionen. \ No newline at end of file +Dieser Plan erweitert den SuperAdmin-Bereich um umfassende Billing- und Security-Management-Funktionen. diff --git a/docs/archive/prp/04-data-model-migrations.md b/docs/archive/prp/04-data-model-migrations.md index 43e9a32..1edef90 100644 --- a/docs/archive/prp/04-data-model-migrations.md +++ b/docs/archive/prp/04-data-model-migrations.md @@ -62,32 +62,7 @@ Schema::create('platform_analytics', function (Blueprint $table) { $table->index(['tenant_id', 'metric_date']); }); -// Event purchases (event credits) -Schema::create('event_purchases', function (Blueprint $table) { - $table->id(); - $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); - $table->unsignedInteger('events_purchased')->default(1); - $table->decimal('amount', 10, 2); - $table->string('currency', 3)->default('EUR'); - $table->string('provider', 32); // app enum: app_store|play_store|stripe|paypal - $table->string('external_receipt_id')->nullable(); - $table->string('status', 16)->default('pending'); // app enum - $table->timestamp('purchased_at')->nullable(); - $table->timestamps(); - $table->index(['tenant_id', 'purchased_at']); -}); - -// Credits ledger -Schema::create('event_credits_ledger', function (Blueprint $table) { - $table->id(); - $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); - $table->integer('delta'); - $table->string('reason', 32); // app enum - $table->foreignId('related_purchase_id')->nullable()->constrained('event_purchases')->nullOnDelete(); - $table->text('note')->nullable(); - $table->timestamps(); - $table->index(['tenant_id', 'created_at']); -}); +// Legacy credits/event_purchases removed; see packages/add-ons schema ``` ## Domain (Event Types, Events, Emotions, Tasks, Photos, Likes) diff --git a/docs/archive/prp/tenant-app-specs/api-usage.md b/docs/archive/prp/tenant-app-specs/api-usage.md index 573aad6..68951f6 100644 --- a/docs/archive/prp/tenant-app-specs/api-usage.md +++ b/docs/archive/prp/tenant-app-specs/api-usage.md @@ -154,32 +154,6 @@ Diese Dokumentation beschreibt alle API-Endpunkte der Tenant Admin App. Alle Req ## Billing -### Balance laden -- **GET /api/v1/tenant/credits/balance** - - **Headers**: `Authorization: Bearer \{token\}` - - **Response**: `{ balance: number }` - -### Ledger-Verlauf -- **GET /api/v1/tenant/credits/ledger** - - **Headers**: `Authorization: Bearer \{token\}` - - **Params**: `page`, `per_page` (Pagination) - - **Response**: `{ data: LedgerEntry[], current_page, last_page }` - - **LedgerEntry**: `{ id, type, amount, credits, date, description, transactionId? }` - -### Credits kaufen (In-App) -- **POST /api/v1/tenant/credits/purchase** - - **Headers**: `Authorization: Bearer \{token\}`, `Content-Type: application/json` - - **Body**: `{ package_id: string, credits_added: number, platform?: 'capacitor'|'web', transaction_id?: string, subscription_active?: boolean }` - - **Response**: `{ message, balance, subscription_active }` - - **Hinweis**: Wird nach erfolgreichen In-App-Kuferfolgen aufgerufen, aktualisiert Balance & Ledger. - -### Credits synchronisieren -- **POST /api/v1/tenant/credits/sync** - - **Headers**: `Authorization: Bearer \{token\}`, `Content-Type: application/json` - - **Body**: `{ balance: number, subscription_active: boolean, last_sync: ISODateString }` - - **Response**: `{ balance, subscription_active, server_time }` - - **Hinweis**: Client meldet lokalen Stand; Server gibt Quelle-der-Wahrheit zurcck. - ### Kauf-Intent erstellen - **POST /api/v1/tenant/purchases/intent** - **Headers**: `Authorization: Bearer \{token\}`, `Content-Type: application/json` diff --git a/docs/process/changes/2025-10-10-tenant-admin-onboarding-plan.md b/docs/process/changes/2025-10-10-tenant-admin-onboarding-plan.md index 19e62a4..41d0ee2 100644 --- a/docs/process/changes/2025-10-10-tenant-admin-onboarding-plan.md +++ b/docs/process/changes/2025-10-10-tenant-admin-onboarding-plan.md @@ -35,7 +35,7 @@ - **Hero experience**: `App.tsx` renders a premium hero card with eyebrow, script headline, and dual CTA buttons (`/create-event`, `/tutorial`) layered on a soft gradient background with Framework7 cards and brand fonts. - **Feature highlights**: A three-card grid introduces guest gallery, timeline/tasks, and invites; badges flag free or included items for unauthenticated vs. subscribed tenants. - **Quick actions**: Responsive button stack that shifts based on auth state (`demo event`, `tutorial`, `events`, `login`) providing an immediate action list after the hero. -- **Credits strip**: `credits-card` combines balance chips, a RevenueCat-aware badge, and CTA to `/credits-store`; replicating this card gives tenants a quick read on package status. +- **Credits strip**: (Legacy) removed in favor of package/add-on status cards. ### Monetisation & Ordering - **IAP store** (`pages/IAPStorePage.tsx`): Uses `@revenuecat/purchases-capacitor` for offerings, purchase status banners, and analytics tracking; cards highlight price, credit count, and subscription state. Needs Paddle parity discussion before porting. diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 5575c1c..d59cc11 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -363,15 +363,7 @@ export type TenantPackageSummary = { export type NotificationPreferences = Record; -export type NotificationPreferencesMeta = { - credit_warning_sent_at?: string | null; - credit_warning_threshold?: number | null; -}; - -export type CreditBalance = { - balance: number; - free_event_granted_at?: string | null; -}; +export type NotificationPreferencesMeta = Record; export type PaddleTransactionSummary = { id: string | null; @@ -1975,37 +1967,6 @@ export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{ return { data: rows, meta }; } -export async function getCreditBalance(): Promise { - const response = await authorizedFetch('/api/v1/tenant/credits/balance'); - if (response.status === 404) { - return { balance: 0 }; - } - const data = await jsonOrThrow(response, 'Failed to load credit balance'); - return { balance: Number(data.balance ?? 0), free_event_granted_at: data.free_event_granted_at ?? null }; -} - -export async function getCreditLedger(page = 1): Promise> { - const response = await authorizedFetch(`/api/v1/tenant/credits/ledger?page=${page}`); - if (!response.ok) { - const payload = await safeJson(response); - console.error('[API] Failed to load credit ledger', response.status, payload); - throw new Error('Failed to load credit ledger'); - } - const json = (await response.json()) as LedgerResponse; - const entries = Array.isArray(json.data) ? json.data.map((entry) => ({ - id: Number(entry.id ?? 0), - delta: Number(entry.delta ?? 0), - reason: String(entry.reason ?? 'unknown'), - note: entry.note ?? null, - related_purchase_id: entry.related_purchase_id ?? null, - created_at: entry.created_at ?? '', - })) : []; - return { - data: entries, - meta: buildPagination(json as JsonValue, entries.length), - }; -} - export async function createTenantPackagePaymentIntent(packageId: number): Promise { const response = await authorizedFetch('/api/v1/tenant/packages/payment-intent', { method: 'POST', @@ -2102,19 +2063,6 @@ export async function recordCreditPurchase(payload: { return { balance: Number(data.balance ?? 0) }; } -export async function syncCreditBalance(payload: { - balance: number; - subscription_active?: boolean; - last_sync?: string; -}): Promise<{ balance: number; subscription_active: boolean; server_time: string }> { - const response = await authorizedFetch('/api/v1/tenant/credits/sync', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }); - return jsonOrThrow(response, 'Failed to sync credit balance'); -} - export async function getTaskCollections(params: { page?: number; per_page?: number; diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx index 4076b71..ab5441f 100644 --- a/resources/js/admin/auth/context.tsx +++ b/resources/js/admin/auth/context.tsx @@ -19,7 +19,6 @@ export interface TenantProfile { name?: string; slug?: string; email?: string | null; - event_credits_balance?: number | null; features?: Record; [key: string]: unknown; } diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 562da77..18cc8bd 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -66,7 +66,6 @@ "generic": "Etwas ist schiefgelaufen. Bitte versuche es erneut.", "eventLimit": "Dein aktuelles Paket enthält keine freien Event-Slots mehr.", "eventLimitDetails": "{used} von {limit} Events genutzt. {remaining} verbleiben.", - "creditsExhausted": "Keine Event-Slots mehr verfügbar. Bitte buche zusätzliche Slots oder upgrade dein Paket.", "photoLimit": "Für dieses Event ist das Foto-Upload-Limit erreicht.", "goToBilling": "Zur Paketverwaltung" }, diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index eb99866..03070d1 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -1390,10 +1390,6 @@ "packageExpired": { "label": "Paket ist abgelaufen", "description": "Benachrichtige mich, wenn das Paket abgelaufen ist." - }, - "creditsLow": { - "label": "Event-Slots werden knapp", - "description": "Informiert mich bei niedrigen Slot-Schwellen." } } } diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 1217ff5..c9f60dd 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -66,7 +66,6 @@ "generic": "Something went wrong. Please try again.", "eventLimit": "Your current package has no remaining event slots.", "eventLimitDetails": "{used} of {limit} events used. {remaining} remaining.", - "creditsExhausted": "You have no event slots remaining. Add more slots or upgrade your package.", "photoLimit": "This event reached its photo upload limit.", "goToBilling": "Manage subscription" }, diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index a70ce28..0b1f098 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -1386,10 +1386,6 @@ "packageExpired": { "label": "Package expired", "description": "Inform me once the package has expired." - }, - "creditsLow": { - "label": "Event slots running low", - "description": "Warn me when slot thresholds are reached." } } } diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 7375d33..5f41502 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -362,11 +362,6 @@ export default function EventFormPage() { setShowUpgradeHint(true); break; } - case 'event_credits_exhausted': { - setError(tErrors('creditsExhausted')); - setShowUpgradeHint(true); - break; - } default: { const metaErrors = Array.isArray(err.meta?.errors) ? err.meta.errors.filter(Boolean).join('\n') : null; setError(metaErrors || err.message || tErrors('generic')); diff --git a/resources/js/admin/pages/SettingsPage.tsx b/resources/js/admin/pages/SettingsPage.tsx index 4d91c6c..554c7fd 100644 --- a/resources/js/admin/pages/SettingsPage.tsx +++ b/resources/js/admin/pages/SettingsPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { AlertTriangle, CheckCircle2, Loader2, Lock, LogOut, Mail, Moon, ShieldCheck, SunMedium, UserCog } from 'lucide-react'; +import { AlertTriangle, Loader2, Lock, LogOut, Mail, Moon, ShieldCheck, SunMedium, UserCog } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import AppearanceToggleDropdown from '@/components/appearance-dropdown'; @@ -25,7 +25,6 @@ import { getNotificationPreferences, updateNotificationPreferences, NotificationPreferences, - NotificationPreferencesMeta, } from '../api'; import { getApiErrorMessage } from '../lib/apiError'; import { useTranslation } from 'react-i18next'; @@ -40,7 +39,6 @@ export default function SettingsPage() { const [loadingNotifications, setLoadingNotifications] = React.useState(true); const [savingNotifications, setSavingNotifications] = React.useState(false); const [notificationError, setNotificationError] = React.useState(null); - const [notificationMeta, setNotificationMeta] = React.useState(null); const heroDescription = t('settings.hero.description', { defaultValue: 'Gestalte das Erlebnis für dein Admin-Team – Darstellung, Benachrichtigungen und Session-Sicherheit.' }); const heroSupporting = [ @@ -222,7 +220,6 @@ export default function SettingsPage() { setPreferences(next)} onReset={() => setPreferences(defaults)} onSave={async () => { @@ -236,9 +233,6 @@ export default function SettingsPage() { if (updated.defaults && Object.keys(updated.defaults).length > 0) { setDefaults(updated.defaults); } - if (updated.meta) { - setNotificationMeta(updated.meta); - } setNotificationError(null); } catch (error) { setNotificationError( @@ -255,7 +249,6 @@ export default function SettingsPage() {
-
@@ -266,7 +259,6 @@ export default function SettingsPage() { function NotificationPreferencesForm({ preferences, defaults, - meta, onChange, onReset, onSave, @@ -275,7 +267,6 @@ function NotificationPreferencesForm({ }: { preferences: NotificationPreferences; defaults: NotificationPreferences; - meta: NotificationPreferencesMeta | null; onChange: (next: NotificationPreferences) => void; onReset: () => void; onSave: () => Promise; @@ -283,22 +274,6 @@ function NotificationPreferencesForm({ translate: (key: string, fallback?: string, options?: Record) => string; }) { const items = React.useMemo(() => buildPreferenceMeta(translate), [translate]); - const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE'; - const creditText = React.useMemo(() => { - if (!meta) { - return null; - } - - if (meta.credit_warning_sent_at) { - const date = formatDateTime(meta.credit_warning_sent_at, locale); - - return translate('settings.notifications.meta.creditLast', 'Letzte Slot-Warnung: {{date}}', { - date, - }); - } - - return translate('settings.notifications.meta.creditNever', 'Noch keine Slot-Warnung versendet.'); - }, [meta, translate, locale]); return (
@@ -332,7 +307,6 @@ function NotificationPreferencesForm({ {translate('settings.notifications.hint', 'Du kannst Benachrichtigungen jederzeit wieder aktivieren.')}
- {creditText ?

{creditText}

: null} ); } @@ -391,11 +365,6 @@ function buildPreferenceMeta( label: translate('settings.notifications.items.packageExpired.label', 'Paket ist abgelaufen'), description: translate('settings.notifications.items.packageExpired.description', 'Benachrichtige mich, wenn das Paket abgelaufen ist.'), }, - { - key: 'credits_low', - label: translate('settings.notifications.items.creditsLow.label', 'Event-Slots werden knapp'), - description: translate('settings.notifications.items.creditsLow.description', 'Informiert mich bei niedrigen Slot-Schwellen.'), - }, ]; return map as Array<{ key: keyof NotificationPreferences; label: string; description: string }>; @@ -414,61 +383,6 @@ function NotificationSkeleton() { ); } -function NotificationMetaCard({ - meta, - loading, - translate, -}: { - meta: NotificationPreferencesMeta | null; - loading: boolean; - translate: (key: string, fallback?: string, options?: Record) => string; -}) { - const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE'; - const lastWarning = meta?.credit_warning_sent_at - ? formatDateTime(meta.credit_warning_sent_at, locale) - : translate('settings.notifications.meta.creditNever', 'Noch keine Slot-Warnung versendet.'); - - return ( - - -
-

{translate('settings.notifications.summary.badge', 'Status')}

-

- {translate('settings.notifications.summary.title', 'Benachrichtigungsübersicht')} -

-
- {loading ? ( -
-
-
-
- ) : ( -
-
- -
-

{translate('settings.notifications.summary.channel', 'E-Mail Kanal')}

-

{translate('settings.notifications.summary.channelCopy', 'Alle Warnungen werden per E-Mail versendet.')}

-
-
-
-

{translate('settings.notifications.summary.credits', 'Credits')}

-

{lastWarning}

- {meta?.credit_warning_threshold ? ( -

- {translate('settings.notifications.summary.threshold', 'Warnung bei {{count}} verbleibenden Slots', { - count: meta.credit_warning_threshold, - })} -

- ) : null} -
-
- )} - - - ); -} - function SupportCard() { const { t } = useTranslation('management'); return ( @@ -492,22 +406,3 @@ function SupportCard() { ); } - -function formatDateTime(value: string, locale: string): string { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return value; - } - - try { - return new Intl.DateTimeFormat(locale, { - day: '2-digit', - month: 'short', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }).format(date); - } catch { - return date.toISOString(); - } -} diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index 2b9158d..2fa0f63 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -125,12 +125,6 @@ return [ 'body' => 'Ihr Paket „:package“ ist am :date abgelaufen. Bitte verlängern oder upgraden Sie, um weitere Events zu erstellen.', 'action' => 'Pakete verwalten', ], - 'credits_low' => [ - 'subject' => 'Event-Credits werden knapp', - 'greeting' => 'Hallo :name,', - 'body' => 'Ihr Tenant hat nur noch :balance Credits (Schwelle: :threshold). Bitte kaufe weitere Credits oder upgrade dein Paket.', - 'action' => 'Credits kaufen oder Paket upgraden', - ], 'footer' => 'Viele Grüße
Ihr Fotospiel-Team', ], diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index f42bd91..b1873fb 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -125,12 +125,6 @@ return [ 'body' => 'Your package ":package" expired on :date. Please renew or upgrade to continue creating events.', 'action' => 'Manage packages', ], - 'credits_low' => [ - 'subject' => 'Event credits are running low', - 'greeting' => 'Hello :name,', - 'body' => 'Your tenant has only :balance credits remaining (threshold: :threshold). Purchase additional credits or upgrade your package to keep creating events.', - 'action' => 'Buy credits or upgrade', - ], 'footer' => 'Best regards,
The Fotospiel Team', ], diff --git a/routes/api.php b/routes/api.php index 712f8fa..eb17e7a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -29,7 +29,6 @@ use App\Http\Controllers\Api\Tenant\TenantFeedbackController; use App\Http\Controllers\Api\TenantBillingController; use App\Http\Controllers\Api\TenantPackageController; use App\Http\Controllers\RevenueCatWebhookController; -use App\Http\Controllers\Tenant\CreditController; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; use Illuminate\Cookie\Middleware\EncryptCookies; use Illuminate\Session\Middleware\StartSession; @@ -260,14 +259,6 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->middleware('tenant.admin') ->name('tenant.notifications.logs.index'); - Route::prefix('credits')->middleware('tenant.admin')->group(function () { - Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance'); - Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); - Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history'); - Route::post('purchase', [CreditController::class, 'purchase'])->name('tenant.credits.purchase'); - Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync'); - }); - Route::prefix('packages')->middleware('tenant.admin')->group(function () { Route::get('/', [PackageController::class, 'index'])->name('packages.index'); Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); diff --git a/tests/Feature/Api/Event/PublicEventErrorResponseTest.php b/tests/Feature/Api/Event/PublicEventErrorResponseTest.php index f34c153..540f905 100644 --- a/tests/Feature/Api/Event/PublicEventErrorResponseTest.php +++ b/tests/Feature/Api/Event/PublicEventErrorResponseTest.php @@ -18,13 +18,7 @@ class PublicEventErrorResponseTest extends TestCase 'error' => ['code', 'title', 'message', 'meta'], ]); - $response->assertJson([ - 'error' => [ - 'code' => 'invalid_token', - 'title' => 'Invalid Join Token', - 'message' => 'The provided join token is invalid.', - ], - ]); + $response->assertJsonPath('error.code', 'invalid_token'); $this->assertSame('not-a-token', $response->json('error.meta.token')); } diff --git a/tests/Feature/Api/HelpControllerTest.php b/tests/Feature/Api/HelpControllerTest.php index 8d4ad61..2d792a3 100644 --- a/tests/Feature/Api/HelpControllerTest.php +++ b/tests/Feature/Api/HelpControllerTest.php @@ -4,7 +4,7 @@ namespace Tests\Feature\Api; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Facades\Cache; use Laravel\Sanctum\Sanctum; use Tests\TestCase; @@ -15,8 +15,24 @@ class HelpControllerTest extends TestCase protected function setUp(): void { parent::setUp(); - Storage::fake('local'); - $this->artisan('help:sync'); + + Cache::put('help.guest.en', collect([ + [ + 'slug' => 'getting-started', + 'title' => 'Getting Started', + 'summary' => 'Welcome guide', + 'body_html' => '

When to read this

', + ], + ]), now()->addMinutes(30)); + + Cache::put('help.admin.en', collect([ + [ + 'slug' => 'tenant-dashboard-overview', + 'title' => 'Dashboard Overview', + 'summary' => 'Overview for admins', + 'body_html' => '

Admin guide

', + ], + ]), now()->addMinutes(30)); } public function test_guest_help_listing_is_public(): void diff --git a/tests/Feature/Auth/TenantProfileApiTest.php b/tests/Feature/Auth/TenantProfileApiTest.php index 1906c69..5b81c12 100644 --- a/tests/Feature/Auth/TenantProfileApiTest.php +++ b/tests/Feature/Auth/TenantProfileApiTest.php @@ -17,7 +17,6 @@ class TenantProfileApiTest extends TestCase $tenant = Tenant::factory()->create([ 'name' => 'Test Tenant GmbH', 'slug' => 'test-tenant', - 'event_credits_balance' => 12, 'features' => ['custom_branding' => true], ]); @@ -53,7 +52,6 @@ class TenantProfileApiTest extends TestCase $me->assertJsonFragment([ 'name' => 'Test Tenant GmbH', 'slug' => 'test-tenant', - 'event_credits_balance' => 12, ]); $data = $me->json(); @@ -69,7 +67,6 @@ class TenantProfileApiTest extends TestCase 'id' => $tenant->id, 'tenant_id' => $tenant->id, 'name' => 'Test Tenant GmbH', - 'event_credits_balance' => 12, 'fullName' => 'Max Mustermann', ]); $legacy->assertJsonStructure([ @@ -79,7 +76,6 @@ class TenantProfileApiTest extends TestCase 'slug', 'email', 'fullName', - 'event_credits_balance', 'active_reseller_package_id', 'remaining_events', 'package_expires_at', diff --git a/tests/Feature/Console/CheckEventPackagesCommandTest.php b/tests/Feature/Console/CheckEventPackagesCommandTest.php index 01ee9c3..7cf99f5 100644 --- a/tests/Feature/Console/CheckEventPackagesCommandTest.php +++ b/tests/Feature/Console/CheckEventPackagesCommandTest.php @@ -5,7 +5,6 @@ namespace Tests\Feature\Console; use App\Console\Commands\CheckEventPackages; use App\Events\Packages\EventPackageGalleryExpired; use App\Events\Packages\EventPackageGalleryExpiring; -use App\Events\Packages\TenantCreditsLow; use App\Events\Packages\TenantPackageExpired; use App\Events\Packages\TenantPackageExpiring; use App\Models\Event; @@ -144,73 +143,4 @@ class CheckEventPackagesCommandTest extends TestCase } } - public function test_dispatches_credit_warning_and_sets_threshold(): void - { - EventFacade::fake(); - - Config::set('package-limits.credit_thresholds', [5, 1]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 5, - 'credit_warning_sent_at' => null, - 'credit_warning_threshold' => null, - ]); - - Artisan::call(CheckEventPackages::class); - - EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { - return $event->tenant->is($tenant) && $event->threshold === 5 && $event->balance === 5; - }); - - $tenant->refresh(); - - $this->assertNotNull($tenant->credit_warning_sent_at); - $this->assertSame(5, $tenant->credit_warning_threshold); - } - - public function test_resets_credit_warning_when_balance_recovers(): void - { - EventFacade::fake(); - - Config::set('package-limits.credit_thresholds', [5, 1]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 10, - 'credit_warning_sent_at' => now()->subDay(), - 'credit_warning_threshold' => 1, - ]); - - Artisan::call(CheckEventPackages::class); - - EventFacade::assertNotDispatched(TenantCreditsLow::class); - - $tenant->refresh(); - - $this->assertNull($tenant->credit_warning_sent_at); - $this->assertNull($tenant->credit_warning_threshold); - } - - public function test_dispatches_lower_credit_threshold_after_higher_warning(): void - { - EventFacade::fake(); - - Config::set('package-limits.credit_thresholds', [5, 1]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 1, - 'credit_warning_sent_at' => now()->subDay(), - 'credit_warning_threshold' => 5, - ]); - - Artisan::call(CheckEventPackages::class); - - EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { - return $event->tenant->is($tenant) && $event->threshold === 1; - }); - - $tenant->refresh(); - - $this->assertSame(1, $tenant->credit_warning_threshold); - $this->assertNotNull($tenant->credit_warning_sent_at); - } } diff --git a/tests/Feature/Dashboard/DashboardPageTest.php b/tests/Feature/Dashboard/DashboardPageTest.php index c05b887..0ecad5d 100644 --- a/tests/Feature/Dashboard/DashboardPageTest.php +++ b/tests/Feature/Dashboard/DashboardPageTest.php @@ -20,9 +20,7 @@ class DashboardPageTest extends TestCase public function test_unverified_user_can_access_dashboard_with_summary_data(): void { - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 4, - ]); + $tenant = Tenant::factory()->create(); $package = Package::factory()->reseller()->create([ 'name_translations' => [ diff --git a/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php b/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php deleted file mode 100644 index f6b82a6..0000000 --- a/tests/Feature/Packages/SendTenantCreditsLowNotificationTest.php +++ /dev/null @@ -1,40 +0,0 @@ -create([ - 'contact_email' => 'admin@example.com', - ]); - - $job = new SendTenantCreditsLowNotification($tenant->id, balance: 5, threshold: 10); - $job->handle(); - - Notification::assertSentOnDemand(TenantCreditsLowNotification::class, function ($notification, $channels, $notifiable) { - return in_array('mail', $channels, true) && ($notifiable->routes['mail'] ?? null) === 'admin@example.com'; - }); - - $tenant->refresh(); - - $this->assertCount(1, $tenant->notificationLogs); - $log = $tenant->notificationLogs()->first(); - $this->assertSame('credits_low', $log->type); - $this->assertSame('sent', $log->status); - $this->assertSame('admin@example.com', $log->recipient); - $this->assertNotNull($log->sent_at); - } -} diff --git a/tests/Feature/Profile/ProfilePageTest.php b/tests/Feature/Profile/ProfilePageTest.php index 187516e..1b2b88e 100644 --- a/tests/Feature/Profile/ProfilePageTest.php +++ b/tests/Feature/Profile/ProfilePageTest.php @@ -18,7 +18,6 @@ class ProfilePageTest extends TestCase public function test_profile_page_displays_user_and_package_information(): void { $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 7, 'subscription_status' => 'active', 'subscription_expires_at' => now()->addMonths(3), ]); diff --git a/tests/Feature/Tenant/DashboardSummaryTest.php b/tests/Feature/Tenant/DashboardSummaryTest.php index 1890dac..d50af03 100644 --- a/tests/Feature/Tenant/DashboardSummaryTest.php +++ b/tests/Feature/Tenant/DashboardSummaryTest.php @@ -23,9 +23,7 @@ class DashboardSummaryTest extends TestCase { app()->setLocale('de'); - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 5, - ]); + $tenant = Tenant::factory()->create(); $eventType = EventType::factory()->create(); @@ -106,10 +104,5 @@ class DashboardSummaryTest extends TestCase $activePackage->expires_at->toIso8601String(), Arr::get($payload, 'active_package.expires_at') ); - - $this->assertSame( - $tenant->event_credits_balance, - Arr::get($payload, 'credit_balance') - ); } } diff --git a/tests/Feature/Tenant/EventCreditsTest.php b/tests/Feature/Tenant/EventCreditsTest.php deleted file mode 100644 index c8bd502..0000000 --- a/tests/Feature/Tenant/EventCreditsTest.php +++ /dev/null @@ -1,59 +0,0 @@ -tenant->update(['event_credits_balance' => 0]); - $eventType = EventType::factory()->create(); - - $package = Package::factory()->create([ - 'type' => 'endcustomer', - 'price' => 0, - 'gallery_days' => 30, - ]); - - $this->tenant->tenantPackages()->create([ - 'package_id' => $package->id, - 'price' => $package->price, - 'purchased_at' => now()->subDay(), - 'expires_at' => now()->addMonth(), - 'active' => true, - ]); - - $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) - ->assertJsonPath('error.code', 'event_credits_exhausted') - ->assertJsonPath('error.meta.balance', 0); - - $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('data.package.id', $package->id); - - $createdEventId = $createResponse->json('data.id'); - - $this->assertNotNull($createdEventId); - $this->assertDatabaseHas('event_packages', [ - 'event_id' => $createdEventId, - 'package_id' => $package->id, - ]); - } -} diff --git a/tests/Feature/Tenant/EventManagementTest.php b/tests/Feature/Tenant/EventManagementTest.php index 8e474b1..bff40a8 100644 --- a/tests/Feature/Tenant/EventManagementTest.php +++ b/tests/Feature/Tenant/EventManagementTest.php @@ -58,10 +58,6 @@ class EventManagementTest extends TenantTestCase 'active' => true, ]); - $this->tenant->update([ - 'event_credits_balance' => 1, - ]); - $payload = [ 'name' => 'Launch Event', 'slug' => 'launch-event', diff --git a/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php b/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php index 20c96bd..0bfeddc 100644 --- a/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php +++ b/tests/Feature/Tenant/Settings/NotificationPreferencesTest.php @@ -37,7 +37,6 @@ class NotificationPreferencesTest extends TestCase 'defaults', 'preferences', 'overrides', - 'meta' => ['credit_warning_sent_at', 'credit_warning_threshold'], ], ]); } diff --git a/tests/Feature/TenantCreditsTest.php b/tests/Feature/TenantCreditsTest.php deleted file mode 100644 index 1fbd218..0000000 --- a/tests/Feature/TenantCreditsTest.php +++ /dev/null @@ -1,86 +0,0 @@ -create([ - 'slug' => 'credits-tenant', - 'event_credits_balance' => 0, - ]); - - $user = User::factory()->create([ - 'tenant_id' => $tenant->id, - 'role' => 'tenant_admin', - 'email' => 'tenant-admin@example.com', - 'password' => Hash::make('password'), - ]); - - $login = $this->postJson('/api/v1/tenant-auth/login', [ - 'login' => $user->email, - 'password' => 'password', - ]); - - $login->assertOk(); - - $accessToken = $login->json('token'); - - $headers = [ - 'Authorization' => 'Bearer '.$accessToken, - ]; - - $balanceResponse = $this->withHeaders($headers) - ->getJson('/api/v1/tenant/credits/balance'); - - $balanceResponse->assertOk() - ->assertJsonStructure(['balance', 'free_event_granted_at']); - - $purchaseResponse = $this->withHeaders($headers) - ->postJson('/api/v1/tenant/credits/purchase', [ - 'package_id' => 'event_starter', - 'credits_added' => 5, - 'platform' => 'capacitor', - 'transaction_id' => 'txn_test_123', - 'subscription_active' => false, - ]); - - $purchaseResponse->assertCreated() - ->assertJsonStructure(['message', 'balance', 'subscription_active']); - - $tenant->refresh(); - $this->assertSame(5, $tenant->event_credits_balance); - - $this->assertDatabaseHas('event_purchases', [ - 'tenant_id' => $tenant->id, - 'events_purchased' => 5, - 'external_receipt_id' => 'txn_test_123', - ]); - - $this->assertDatabaseHas('event_credits_ledger', [ - 'tenant_id' => $tenant->id, - 'delta' => 5, - 'reason' => 'purchase', - ]); - - $syncResponse = $this->withHeaders($headers) - ->postJson('/api/v1/tenant/credits/sync', [ - 'balance' => $tenant->event_credits_balance, - 'subscription_active' => false, - 'last_sync' => now()->toIso8601String(), - ]); - - $syncResponse->assertOk() - ->assertJsonStructure(['balance', 'subscription_active', 'server_time']); - } -} - diff --git a/tests/Unit/AdminDashboardWidgetsTest.php b/tests/Unit/AdminDashboardWidgetsTest.php index ca3d154..a91f530 100644 --- a/tests/Unit/AdminDashboardWidgetsTest.php +++ b/tests/Unit/AdminDashboardWidgetsTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit; -use App\Filament\Widgets\CreditAlertsWidget; use App\Filament\Widgets\RevenueTrendWidget; use App\Models\PurchaseHistory; use App\Models\Tenant; @@ -21,67 +20,17 @@ class AdminDashboardWidgetsTest extends TestCase Carbon::setTestNow(); } - public function test_credit_alerts_widget_cards_reflect_metrics(): void - { - $lowBalanceTenant = Tenant::factory()->create([ - 'event_credits_balance' => 2, - 'is_active' => true, - 'subscription_expires_at' => now()->addMonths(2), - ]); - - Tenant::factory()->create([ - 'event_credits_balance' => 20, - 'is_active' => true, - 'subscription_expires_at' => now()->addMonths(1), - ]); - - Tenant::factory()->create([ - 'event_credits_balance' => 1, - 'is_active' => false, - 'subscription_expires_at' => now()->subDay(), - ]); - - PurchaseHistory::create([ - 'id' => 'ph-1', - 'tenant_id' => $lowBalanceTenant->id, - 'package_id' => 'starter_pack', - 'credits_added' => 5, - 'price' => 149.90, - 'currency' => 'EUR', - 'platform' => 'web', - 'transaction_id' => 'txn-1', - 'purchased_at' => now()->startOfMonth()->addDay(), - 'created_at' => now(), - ]); - - $widget = new CreditAlertsWidget(); - - $cards = $this->invokeProtectedMethod($widget, 'getCards'); - - $this->assertCount(3, $cards); - $this->assertSame( - __('admin.widgets.credit_alerts.low_balance_label'), - $cards[0]->getLabel() - ); - $this->assertSame(1, $cards[0]->getValue()); - $this->assertSame( - 2, - $cards[2]->getValue() - ); - $this->assertStringContainsString('149.9', (string) $cards[1]->getValue()); - } - public function test_revenue_trend_widget_compiles_monthly_totals(): void { Carbon::setTestNow(Carbon::create(2025, 10, 20, 12)); $tenant = Tenant::factory()->create(); + $packagePro = \App\Models\Package::factory()->create(['name' => 'Pro Pack']); + $packageStarter = \App\Models\Package::factory()->create(['name' => 'Starter Pack']); PurchaseHistory::create([ - 'id' => 'cur-1', 'tenant_id' => $tenant->id, - 'package_id' => 'pro_pack', - 'credits_added' => 10, + 'package_id' => $packagePro->id, 'price' => 299.99, 'currency' => 'EUR', 'platform' => 'web', @@ -91,10 +40,8 @@ class AdminDashboardWidgetsTest extends TestCase ]); PurchaseHistory::create([ - 'id' => 'prev-1', 'tenant_id' => $tenant->id, - 'package_id' => 'starter_pack', - 'credits_added' => 5, + 'package_id' => $packageStarter->id, 'price' => 149.90, 'currency' => 'EUR', 'platform' => 'web', diff --git a/tests/Unit/ProcessRevenueCatWebhookTest.php b/tests/Unit/ProcessRevenueCatWebhookTest.php deleted file mode 100644 index a6ea9eb..0000000 --- a/tests/Unit/ProcessRevenueCatWebhookTest.php +++ /dev/null @@ -1,63 +0,0 @@ -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); - $expected = $expiresAt->clone()->setTimezone(config('app.timezone')); - $this->assertLessThanOrEqual(3600, abs($tenant->subscription_expires_at->timestamp - $expected->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); - } -} diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php index fdcc8bc..2dbad0f 100644 --- a/tests/Unit/Services/PackageLimitEvaluatorTest.php +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -26,7 +26,13 @@ class PackageLimitEvaluatorTest extends TestCase public function test_assess_event_creation_returns_null_when_allowance_available(): void { - $tenant = Tenant::factory()->create(['event_credits_balance' => 2]); + $tenant = Tenant::factory()->create(); + $package = Package::factory()->reseller()->create(['max_events_per_year' => 5]); + TenantPackage::factory()->for($tenant)->for($package)->create([ + 'used_events' => 1, + 'expires_at' => now()->addMonth(), + 'active' => true, + ]); $violation = $this->evaluator->assessEventCreation($tenant); @@ -39,7 +45,7 @@ class PackageLimitEvaluatorTest extends TestCase 'max_events_per_year' => 1, ]); - $tenant = Tenant::factory()->create(['event_credits_balance' => 0]); + $tenant = Tenant::factory()->create(); TenantPackage::factory()->create([ 'tenant_id' => $tenant->id, @@ -59,17 +65,6 @@ class PackageLimitEvaluatorTest extends TestCase $this->assertSame(0, $violation['meta']['remaining']); } - public function test_assess_event_creation_returns_credit_violation_when_no_credits(): void - { - $tenant = Tenant::factory()->create(['event_credits_balance' => 0]); - - $violation = $this->evaluator->assessEventCreation($tenant); - - $this->assertNotNull($violation); - $this->assertSame('event_credits_exhausted', $violation['code']); - $this->assertSame('credits', $violation['meta']['scope']); - } - public function test_assess_photo_upload_returns_violation_when_photo_limit_reached(): void { $package = Package::factory()->endcustomer()->create([ diff --git a/tests/Unit/Services/TenantUsageTrackerTest.php b/tests/Unit/Services/TenantUsageTrackerTest.php index b49ee87..6092029 100644 --- a/tests/Unit/Services/TenantUsageTrackerTest.php +++ b/tests/Unit/Services/TenantUsageTrackerTest.php @@ -2,7 +2,6 @@ namespace Tests\Unit\Services; -use App\Events\Packages\TenantCreditsLow; use App\Events\Packages\TenantPackageEventLimitReached; use App\Events\Packages\TenantPackageEventThresholdReached; use App\Models\Package; @@ -85,31 +84,4 @@ class TenantUsageTrackerTest extends TestCase $this->assertNotNull($tenantPackage->event_limit_notified_at); } - - public function test_record_credit_balance_dispatches_event_and_updates_tenant(): void - { - EventFacade::fake([TenantCreditsLow::class]); - - Config::set('package-limits.credit_thresholds', [5, 1]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 5, - 'credit_warning_sent_at' => null, - 'credit_warning_threshold' => null, - ]); - - /** @var TenantUsageTracker $tracker */ - $tracker = app(TenantUsageTracker::class); - - $tracker->recordCreditBalance($tenant, 6, 5); - - EventFacade::assertDispatched(TenantCreditsLow::class, function ($event) use ($tenant) { - return $event->tenant->is($tenant) && $event->threshold === 5; - }); - - $tenant->refresh(); - - $this->assertNotNull($tenant->credit_warning_sent_at); - $this->assertSame(5, $tenant->credit_warning_threshold); - } } diff --git a/tests/Unit/TenantCreditTest.php b/tests/Unit/TenantCreditTest.php deleted file mode 100644 index d4b37c7..0000000 --- a/tests/Unit/TenantCreditTest.php +++ /dev/null @@ -1,68 +0,0 @@ -reseller() - ->create([ - 'max_events_per_year' => 5, - ]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 0, - ]); - - TenantPackage::factory()->for($tenant)->for($package)->create([ - 'used_events' => 1, - 'active' => true, - ]); - - $this->assertTrue($tenant->consumeEventAllowance()); - - $updatedPackage = $tenant->getActiveResellerPackage(); - $this->assertNotNull($updatedPackage); - $this->assertSame(2, $updatedPackage->used_events); - } - - public function test_consume_event_allowance_decrements_credits_when_no_package(): void - { - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 2, - ]); - - $this->assertTrue($tenant->consumeEventAllowance(1, 'event.create', 'Event #1 created')); - - $tenant->refresh(); - $this->assertSame(1, $tenant->event_credits_balance); - - $this->assertDatabaseHas('event_credits_ledger', [ - 'tenant_id' => $tenant->id, - 'delta' => -1, - 'reason' => 'event.create', - 'note' => 'Event #1 created', - ]); - } - - public function test_consume_event_allowance_returns_false_without_package_or_credits(): void - { - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 0, - ]); - - $this->assertFalse($tenant->consumeEventAllowance()); - - $this->assertDatabaseCount('event_credits_ledger', 0); - } -} diff --git a/tests/Unit/TenantModelTest.php b/tests/Unit/TenantModelTest.php index 9d2b8b7..1403b5d 100644 --- a/tests/Unit/TenantModelTest.php +++ b/tests/Unit/TenantModelTest.php @@ -146,22 +146,4 @@ class TenantModelTest extends TestCase $this->assertFalse($tenant->features['analytics']); } - public function test_increment_credits_clears_warning_when_balance_above_threshold(): void - { - Config::set('package-limits.credit_thresholds', [5, 1]); - - $tenant = Tenant::factory()->create([ - 'event_credits_balance' => 1, - 'credit_warning_sent_at' => now()->subDay(), - 'credit_warning_threshold' => 1, - ]); - - $tenant->incrementCredits(10); - - $tenant->refresh(); - - $this->assertNull($tenant->credit_warning_sent_at); - $this->assertNull($tenant->credit_warning_threshold); - $this->assertSame(11, (int) $tenant->event_credits_balance); - } } diff --git a/tests/Unit/TenantPolicyTest.php b/tests/Unit/TenantPolicyTest.php deleted file mode 100644 index e71d102..0000000 --- a/tests/Unit/TenantPolicyTest.php +++ /dev/null @@ -1,72 +0,0 @@ -policy = new TenantPolicy(); - } - - public function test_super_admin_can_adjust_credits(): void - { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create([ - 'role' => 'super_admin', - ]); - - $this->assertTrue($this->policy->adjustCredits($user, $tenant)); - } - - public function test_tenant_admin_cannot_adjust_credits(): void - { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create([ - 'role' => 'tenant_admin', - ]); - - $user->forceFill(['tenant_id' => $tenant->id])->save(); - - $this->assertFalse($this->policy->adjustCredits($user, $tenant)); - } - - public function test_tenant_admin_can_view_own_tenant(): void - { - $tenant = Tenant::factory()->create(); - $user = User::factory()->create([ - 'role' => 'tenant_admin', - ]); - - $user->forceFill(['tenant_id' => $tenant->id])->save(); - - $this->assertTrue($this->policy->view($user, $tenant)); - } - - public function test_tenant_admin_cannot_view_other_tenant(): void - { - $tenant = Tenant::factory()->create(); - $otherTenant = Tenant::factory()->create(); - - $user = User::factory()->create([ - 'role' => 'tenant_admin', - ]); - - $user->forceFill(['tenant_id' => $tenant->id])->save(); - - $this->assertFalse($this->policy->view($user, $otherTenant)); - } -} -