diff --git a/app/Console/Commands/MigrateToPackages.php b/app/Console/Commands/MigrateToPackages.php new file mode 100644 index 0000000..1558b3c --- /dev/null +++ b/app/Console/Commands/MigrateToPackages.php @@ -0,0 +1,93 @@ +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', + 'purchased_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, + 'purchased_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_event', + 'provider_id' => 'migration', + 'purchased_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/Filament/Resources/EventResource.php b/app/Filament/Resources/EventResource.php index d4cf358..55e34f5 100644 --- a/app/Filament/Resources/EventResource.php +++ b/app/Filament/Resources/EventResource.php @@ -15,12 +15,14 @@ use Filament\Forms\Form; use Filament\Schemas\Schema; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\DatePicker; -use Filament\Forms\Components\Select; use Filament\Forms\Components\Toggle; use Filament\Forms\Components\KeyValue; +use Filament\Forms\Components\Select; use UnitEnum; use BackedEnum; +use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager; + class EventResource extends Resource { protected static ?string $model = Event::class; @@ -57,6 +59,12 @@ class EventResource extends Resource ->label(__('admin.events.fields.type')) ->options(EventType::all()->pluck('name', 'id')) ->searchable(), + Select::make('package_id') + ->label(__('admin.events.fields.package')) + ->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id')) + ->searchable() + ->preload() + ->required(), TextInput::make('default_locale') ->label(__('admin.events.fields.default_locale')) ->default('de') @@ -82,6 +90,18 @@ class EventResource extends Resource Tables\Columns\TextColumn::make('date')->date(), Tables\Columns\IconColumn::make('is_active')->boolean(), Tables\Columns\TextColumn::make('default_locale'), + Tables\Columns\TextColumn::make('eventPackage.package.name') + ->label(__('admin.events.table.package')) + ->badge() + ->color('success'), + Tables\Columns\TextColumn::make('eventPackage.used_photos') + ->label(__('admin.events.table.used_photos')) + ->badge(), + Tables\Columns\TextColumn::make('eventPackage.remaining_photos') + ->label(__('admin.events.table.remaining_photos')) + ->badge() + ->color(fn ($state) => $state < 1 ? 'danger' : 'success') + ->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0), Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join')) ->getStateUsing(fn($record) => url("/e/{$record->slug}")) ->copyable() @@ -117,4 +137,11 @@ class EventResource extends Resource 'edit' => Pages\EditEvent::route('/{record}/edit'), ]; } + + public static function getRelations(): array + { + return [ + EventPackagesRelationManager::class, + ]; + } } diff --git a/app/Filament/Resources/EventResource/RelationManagers/EventPackagesRelationManager.php b/app/Filament/Resources/EventResource/RelationManagers/EventPackagesRelationManager.php new file mode 100644 index 0000000..d9278b7 --- /dev/null +++ b/app/Filament/Resources/EventResource/RelationManagers/EventPackagesRelationManager.php @@ -0,0 +1,129 @@ +schema([ + Select::make('package_id') + ->label('Package') + ->relationship('package', 'name') + ->searchable() + ->preload() + ->required(), + TextInput::make('purchased_price') + ->label('Kaufpreis') + ->prefix('€') + ->numeric() + ->step(0.01) + ->required(), + TextInput::make('used_photos') + ->label('Verwendete Fotos') + ->numeric() + ->default(0) + ->readOnly(), + TextInput::make('used_guests') + ->label('Verwendete Gäste') + ->numeric() + ->default(0) + ->readOnly(), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('package.name') + ->columns([ + TextColumn::make('package.name') + ->label('Package') + ->badge() + ->color('success'), + TextColumn::make('used_photos') + ->label('Verwendete Fotos') + ->badge(), + TextColumn::make('remaining_photos') + ->label('Verbleibende Fotos') + ->badge() + ->color(fn ($state) => $state < 1 ? 'danger' : 'success') + ->getStateUsing(fn (EventPackage $record) => $record->remaining_photos), + TextColumn::make('used_guests') + ->label('Verwendete Gäste') + ->badge(), + TextColumn::make('remaining_guests') + ->label('Verbleibende Gäste') + ->badge() + ->color(fn ($state) => $state < 1 ? 'danger' : 'success') + ->getStateUsing(fn (EventPackage $record) => $record->remaining_guests), + TextColumn::make('expires_at') + ->label('Ablauf') + ->dateTime() + ->badge() + ->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'), + TextColumn::make('purchased_price') + ->label('Preis') + ->money('EUR') + ->sortable(), + ]) + ->filters([ + // + ]) + ->headerActions([ + CreateAction::make(), + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public function getRelationExistenceQuery( + Builder $query, + string $relationshipName, + ?string $ownerKeyName, + mixed $ownerKeyValue, + ): Builder { + return $query; + } + + public static function getTitle(Model $ownerRecord, string $pageClass): string + { + return __('admin.events.relation_managers.event_packages.title'); + } + + public function getTableQuery(): Builder | Relation + { + return parent::getTableQuery() + ->with('package'); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php new file mode 100644 index 0000000..51837bc --- /dev/null +++ b/app/Filament/Resources/PackageResource.php @@ -0,0 +1,153 @@ +schema([ + TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Select::make('type') + ->label('Type') + ->options([ + 'endcustomer' => 'Endcustomer', + 'reseller' => 'Reseller', + ]) + ->required(), + TextInput::make('price') + ->label('Price') + ->prefix('€') + ->numeric() + ->step(0.01) + ->required() + ->default(0), + TextInput::make('max_photos') + ->label('Max Photos') + ->numeric() + ->nullable(), + TextInput::make('max_guests') + ->label('Max Guests') + ->numeric() + ->nullable(), + TextInput::make('gallery_days') + ->label('Gallery Days') + ->numeric() + ->nullable(), + TextInput::make('max_tasks') + ->label('Max Tasks') + ->numeric() + ->nullable(), + Toggle::make('watermark_allowed') + ->label('Watermark Allowed') + ->default(true), + Toggle::make('branding_allowed') + ->label('Branding Allowed') + ->default(false), + TextInput::make('max_events_per_year') + ->label('Max Events per Year') + ->numeric() + ->nullable(), + Repeater::make('features') + ->label('Features') + ->schema([ + TextInput::make('key') + ->label('Feature Key'), + TextInput::make('value') + ->label('Feature Value'), + ]) + ->columns(2) + ->defaultItems(0), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('name') + ->label('Name') + ->searchable() + ->sortable(), + TextColumn::make('type') + ->label('Type') + ->badge() + ->color(fn (string $state): string => match ($state) { + 'endcustomer' => 'info', + 'reseller' => 'warning', + default => 'gray', + }), + TextColumn::make('price') + ->label('Price') + ->money('EUR') + ->sortable(), + IconColumn::make('max_photos') + ->label('Max Photos') + ->icon('heroicon-o-photo') + ->color('primary'), + TextColumn::make('features') + ->label('Features') + ->limit(50), + ]) + ->filters([ + // + ]) + ->actions([ + EditAction::make(), + DeleteAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPackages::route('/'), + 'create' => Pages\CreatePackage::route('/create'), + 'edit' => Pages\EditPackage::route('/{record}/edit'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantResource.php b/app/Filament/Resources/TenantResource.php index ba3cb34..3748db2 100644 --- a/app/Filament/Resources/TenantResource.php +++ b/app/Filament/Resources/TenantResource.php @@ -17,7 +17,8 @@ use Filament\Forms\Components\KeyValue; use Filament\Forms\Components\Select; use Filament\Forms\Components\DateTimePicker; use Filament\Tables\Columns\IconColumn; -use App\Filament\Resources\TenantResource\RelationManagers\PurchasesRelationManager; +use App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager; +use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager; use Filament\Resources\RelationManagers\RelationGroup; use UnitEnum; use BackedEnum; @@ -52,28 +53,23 @@ class TenantResource extends Resource ->email() ->required() ->maxLength(255), - TextInput::make('event_credits_balance') - ->label(__('admin.tenants.fields.event_credits_balance')) - ->numeric() - ->default(0), - Select::make('subscription_tier') - ->label(__('admin.tenants.fields.subscription_tier')) - ->options([ - 'free' => 'Free', - 'starter' => 'Starter (€4.99/mo)', - 'pro' => 'Pro (€14.99/mo)', - 'agency' => 'Agency (€19.99/mo)', - 'lifetime' => 'Lifetime (€49.99)' - ]) - ->default('free'), - DateTimePicker::make('subscription_expires_at') - ->label(__('admin.tenants.fields.subscription_expires_at')), TextInput::make('total_revenue') ->label(__('admin.tenants.fields.total_revenue')) ->prefix('€') ->numeric() ->step(0.01) ->readOnly(), + Select::make('active_reseller_package_id') + ->label(__('admin.tenants.fields.active_reseller_package')) + ->relationship('activeResellerPackage', 'name') + ->searchable() + ->preload() + ->nullable(), + TextInput::make('remaining_events') + ->label(__('admin.tenants.fields.remaining_events')) + ->readOnly() + ->dehydrated(false) + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0), Toggle::make('is_active') ->label(__('admin.tenants.fields.is_active')) ->default(true), @@ -95,19 +91,21 @@ class TenantResource extends Resource Tables\Columns\TextColumn::make('name')->searchable()->sortable(), Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('contact_email'), - Tables\Columns\TextColumn::make('event_credits_balance') - ->label(__('admin.common.credits')) + Tables\Columns\TextColumn::make('activeResellerPackage.name') + ->label(__('admin.tenants.fields.active_package')) ->badge() - ->color(fn ($state) => $state < 5 ? 'warning' : 'success'), - Tables\Columns\TextColumn::make('subscription_tier') + ->color('success'), + Tables\Columns\TextColumn::make('remaining_events') + ->label(__('admin.tenants.fields.remaining_events')) ->badge() - ->color(fn (string $state): string => match($state) { - 'free' => 'gray', - 'starter' => 'info', - 'pro' => 'success', - 'agency' => 'warning', - 'lifetime' => 'danger', - }), + ->color(fn ($state) => $state < 1 ? 'danger' : 'success') + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0), + Tables\Columns\TextColumn::make('activeResellerPackage.expires_at') + ->dateTime() + ->label(__('admin.tenants.fields.package_expires_at')) + ->badge() + ->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success') + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->expires_at), Tables\Columns\TextColumn::make('total_revenue') ->money('EUR') ->sortable(), @@ -120,23 +118,36 @@ class TenantResource extends Resource ->filters([]) ->actions([ Actions\EditAction::make(), - Actions\Action::make('add_credits') - ->label('Credits hinzufügen') + Actions\Action::make('add_package') + ->label('Package hinzufügen') ->icon('heroicon-o-plus') ->form([ - Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1), + Select::make('package_id') + ->label('Package') + ->options(\App\Models\Package::where('type', 'reseller')->pluck('name', 'id')) + ->searchable() + ->preload() + ->required(), + Forms\Components\DateTimePicker::make('expires_at') + ->label('Ablaufdatum') + ->default(now()->addYear()), Forms\Components\Textarea::make('reason')->label('Grund')->rows(3), ]) ->action(function (Tenant $record, array $data) { - $record->increment('event_credits_balance', $data['credits']); - \App\Models\EventPurchase::create([ + \App\Models\TenantPackage::create([ 'tenant_id' => $record->id, - 'package_id' => 'manual_adjustment', - 'credits_added' => $data['credits'], - 'price' => 0, - 'platform' => 'manual', - 'transaction_id' => null, - 'reason' => $data['reason'], + 'package_id' => $data['package_id'], + 'expires_at' => $data['expires_at'], + 'active' => true, + 'reason' => $data['reason'] ?? null, + ]); + \App\Models\PackagePurchase::create([ + 'tenant_id' => $record->id, + 'package_id' => $data['package_id'], + 'provider_id' => 'manual', + 'type' => 'reseller_subscription', + 'purchased_price' => 0, + 'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'], ]); }), Actions\Action::make('suspend') @@ -164,4 +175,12 @@ class TenantResource extends Resource 'edit' => Pages\EditTenant::route('/{record}/edit'), ]; } + + public static function getRelations(): array + { + return [ + TenantPackagesRelationManager::class, + PackagePurchasesRelationManager::class, + ]; + } } diff --git a/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php new file mode 100644 index 0000000..f4427bd --- /dev/null +++ b/app/Filament/Resources/TenantResource/RelationManagers/PackagePurchasesRelationManager.php @@ -0,0 +1,143 @@ +schema([ + Select::make('package_id') + ->label('Paket') + ->relationship('package', 'name') + ->searchable() + ->preload() + ->required(), + Select::make('type') + ->label('Typ') + ->options([ + 'endcustomer_event' => 'Endkunden-Event', + 'reseller_subscription' => 'Reseller-Abo', + ]) + ->required(), + TextInput::make('purchased_price') + ->label('Gekaufter Preis') + ->numeric() + ->step(0.01) + ->prefix('€') + ->required(), + Select::make('provider_id') + ->label('Anbieter') + ->options([ + 'stripe' => 'Stripe', + 'paypal' => 'PayPal', + 'manual' => 'Manuell', + 'free' => 'Kostenlos', + ]) + ->required(), + TextInput::make('transaction_id') + ->label('Transaktions-ID') + ->maxLength(255), + Toggle::make('refunded') + ->label('Rückerstattet'), + Textarea::make('metadata') + ->label('Metadaten') + ->json() + ->columnSpanFull(), + ]) + ->columns(2); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('package.name') + ->columns([ + TextColumn::make('package.name') + ->label('Paket') + ->badge() + ->color('success'), + TextColumn::make('type') + ->badge() + ->color(fn (string $state): string => match($state) { + 'endcustomer_event' => 'info', + 'reseller_subscription' => 'success', + default => 'gray', + }), + TextColumn::make('purchased_price') + ->money('EUR') + ->sortable(), + TextColumn::make('provider_id') + ->badge() + ->color(fn (string $state): string => match($state) { + 'stripe' => 'info', + 'paypal' => 'warning', + 'manual' => 'gray', + 'free' => 'success', + }), + TextColumn::make('transaction_id') + ->copyable() + ->toggleable(), + TextColumn::make('metadata') + ->label('Metadaten') + ->toggleable(), + IconColumn::make('refunded') + ->boolean() + ->color(fn (bool $state): string => $state ? 'danger' : 'success'), + TextColumn::make('created_at') + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('type') + ->options([ + 'endcustomer_event' => 'Endkunden-Event', + 'reseller_subscription' => 'Reseller-Abo', + ]), + SelectFilter::make('provider_id') + ->options([ + 'stripe' => 'Stripe', + 'paypal' => 'PayPal', + 'manual' => 'Manuell', + 'free' => 'Kostenlos', + ]), + SelectFilter::make('refunded') + ->options([ + '1' => 'Rückerstattet', + '0' => 'Nicht rückerstattet', + ]), + ]) + ->headerActions([]) + ->actions([ + ViewAction::make(), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php new file mode 100644 index 0000000..1a4814c --- /dev/null +++ b/app/Filament/Resources/TenantResource/RelationManagers/TenantPackagesRelationManager.php @@ -0,0 +1,108 @@ +schema([ + Select::make('package_id') + ->label('Paket') + ->relationship('package', 'name') + ->searchable() + ->preload() + ->required(), + DateTimePicker::make('expires_at') + ->label('Ablaufdatum') + ->required(), + Toggle::make('active') + ->label('Aktiv'), + Textarea::make('reason') + ->label('Grund') + ->maxLength(65535) + ->columnSpanFull(), + ]) + ->columns(2); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('package.name') + ->columns([ + TextColumn::make('package.name') + ->label('Paket') + ->badge() + ->color('success'), + TextColumn::make('used_events') + ->label('Genutzte Events') + ->badge(), + TextColumn::make('remaining_events') + ->label('Verbleibende Events') + ->badge() + ->color(fn ($state) => $state < 1 ? 'danger' : 'success') + ->getStateUsing(fn ($record) => $record->remaining_events), + TextColumn::make('expires_at') + ->dateTime() + ->sortable(), + IconColumn::make('active') + ->boolean() + ->color(fn (bool $state): string => $state ? 'success' : 'danger'), + TextColumn::make('created_at') + ->dateTime() + ->sortable(), + ]) + ->filters([ + SelectFilter::make('active') + ->options([ + '1' => 'Aktiv', + '0' => 'Inaktiv', + ]), + ]) + ->headerActions([]) + ->actions([ + EditAction::make(), + Action::make('activate') + ->label('Aktivieren') + ->icon('heroicon-o-check-circle') + ->color('success') + ->action(fn ($record) => $record->update(['active' => true])), + Action::make('deactivate') + ->label('Deaktivieren') + ->icon('heroicon-o-x-circle') + ->color('danger') + ->requiresConfirmation() + ->action(fn ($record) => $record->update(['active' => false])), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php new file mode 100644 index 0000000..440c44a --- /dev/null +++ b/app/Http/Controllers/Api/PackageController.php @@ -0,0 +1,116 @@ +query('type', 'endcustomer'); + $packages = Package::where('type', $type) + ->orderBy('price') + ->get(); + + $packages->each(function ($package) { + $package->features = json_decode($package->features ?? '[]', true); + }); + + return response()->json([ + 'data' => $packages, + 'message' => "Packages for type '{$type}' loaded successfully.", + ]); + } + + public function purchase(Request $request): JsonResponse + { + $request->validate([ + 'package_id' => 'required|exists:packages,id', + 'type' => 'required|in:endcustomer_event,reseller_subscription', + 'payment_method' => 'required|in:stripe,paypal', + 'event_id' => 'nullable|exists:events,id', // For endcustomer + ]); + + $package = Package::findOrFail($request->package_id); + $tenant = $request->attributes->get('tenant'); + + if (!$tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); + } + + if ($package->price == 0) { + // Free package - direct assignment + return $this->handleFreePurchase($request, $package, $tenant); + } + + // Paid purchase + return $this->handlePaidPurchase($request, $package, $tenant); + } + + private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse + { + DB::transaction(function () use ($request, $package, $tenant) { + $purchaseData = [ + 'tenant_id' => $tenant->id, + 'event_id' => $request->event_id, + 'package_id' => $package->id, + 'provider_id' => 'free', + 'price' => $package->price, + 'type' => $request->type, + 'metadata' => json_encode([ + 'note' => 'Free package assigned', + 'ip' => $request->ip(), + ]), + ]; + + PackagePurchase::create($purchaseData); + + if ($request->event_id) { + // Assign to event + \App\Models\EventPackage::create([ + 'event_id' => $request->event_id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + ]); + } else { + // Reseller subscription + \App\Models\TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'price' => $package->price, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + 'active' => true, + ]); + } + }); + + return response()->json([ + 'message' => 'Free package assigned successfully.', + 'purchase' => ['package' => $package->name, 'type' => $request->type], + ], 201); + } + + private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse + { + $type = $request->type; + + if ($type === 'reseller_subscription') { + $response = (new StripeController())->createSubscription($request); + return $response; + } else { + $response = (new StripeController())->createPaymentIntent($request); + return $response; + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/StripeController.php b/app/Http/Controllers/Api/StripeController.php new file mode 100644 index 0000000..d1dc781 --- /dev/null +++ b/app/Http/Controllers/Api/StripeController.php @@ -0,0 +1,100 @@ +validate([ + 'package_id' => 'required|exists:packages,id', + 'type' => 'required|in:endcustomer_event,reseller_subscription', + 'tenant_id' => 'nullable|exists:tenants,id', // For reseller + 'event_id' => 'nullable|exists:events,id', // For endcustomer + ]); + + $package = \App\Models\Package::findOrFail($request->package_id); + + $amount = $package->price * 100; // Cents + + $metadata = [ + 'package_id' => $package->id, + 'type' => $request->type, + ]; + + if ($request->tenant_id) { + $metadata['tenant_id'] = $request->tenant_id; + } + + if ($request->event_id) { + $metadata['event_id'] = $request->event_id; + } + + $intent = PaymentIntent::create([ + 'amount' => $amount, + 'currency' => 'eur', + 'metadata' => $metadata, + ]); + + return response()->json([ + 'client_secret' => $intent->client_secret, + ]); + } + + public function createSubscription(Request $request) + { + $request->validate([ + 'package_id' => 'required|exists:packages,id', + 'tenant_id' => 'required|exists:tenants,id', + ]); + + $package = \App\Models\Package::findOrFail($request->package_id); + $tenant = \App\Models\Tenant::findOrFail($request->tenant_id); + + // Assume customer exists or create + $customer = $tenant->stripe_customer_id ? \Stripe\Customer::retrieve($tenant->stripe_customer_id) : \Stripe\Customer::create([ + 'email' => $tenant->email, + 'metadata' => ['tenant_id' => $tenant->id], + ]); + + $subscription = Subscription::create([ + 'customer' => $customer->id, + 'items' => [[ + 'price' => $package->stripe_price_id, // Assume price ID set in package + ]], + 'metadata' => [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + ]); + + // Create initial tenant package + TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'stripe_subscription_id' => $subscription->id, + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + + return response()->json([ + 'subscription_id' => $subscription->id, + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/StripeWebhookController.php b/app/Http/Controllers/Api/StripeWebhookController.php new file mode 100644 index 0000000..1c9641b --- /dev/null +++ b/app/Http/Controllers/Api/StripeWebhookController.php @@ -0,0 +1,136 @@ +getContent(); + $sigHeader = $request->header('Stripe-Signature'); + $endpointSecret = config('services.stripe.webhook_secret'); + + try { + $event = Webhook::constructEvent( + $payload, $sigHeader, $endpointSecret + ); + } catch (SignatureVerificationException $e) { + return response()->json(['error' => 'Invalid signature'], 400); + } catch (\UnexpectedValueException $e) { + return response()->json(['error' => 'Invalid payload'], 400); + } + + // Handle the event + switch ($event['type']) { + case 'payment_intent.succeeded': + $paymentIntent = $event['data']['object']; + $this->handlePaymentIntentSucceeded($paymentIntent); + break; + + case 'invoice.paid': + $invoice = $event['data']['object']; + $this->handleInvoicePaid($invoice); + break; + + default: + \Log::info('Unhandled Stripe event', ['type' => $event['type']]); + } + + return response()->json(['status' => 'success'], 200); + } + + private function handlePaymentIntentSucceeded(array $paymentIntent) + { + $metadata = $paymentIntent['metadata']; + $packageId = $metadata['package_id']; + $type = $metadata['type']; + + \DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) { + // Create purchase record + $purchase = PackagePurchase::create([ + 'package_id' => $packageId, + 'type' => $type, + 'provider_id' => 'stripe', + 'transaction_id' => $paymentIntent['id'], + 'purchased_price' => $paymentIntent['amount_received'] / 100, + 'metadata' => $metadata, + ]); + + if ($type === 'endcustomer_event') { + $eventId = $metadata['event_id']; + EventPackage::create([ + 'event_id' => $eventId, + 'package_id' => $packageId, + 'package_purchase_id' => $purchase->id, + 'used_photos' => 0, + 'used_guests' => 0, + 'expires_at' => now()->addDays(30), // Default, or from package + ]); + } elseif ($type === 'reseller_subscription') { + $tenantId = $metadata['tenant_id']; + TenantPackage::create([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'package_purchase_id' => $purchase->id, + 'used_events' => 0, + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + } + }); + } + + private function handleInvoicePaid(array $invoice) + { + $subscription = $invoice['subscription']; + $metadata = $subscription['metadata'] ?? []; + + if (isset($metadata['tenant_id'])) { + $tenantId = $metadata['tenant_id']; + $packageId = $metadata['package_id']; + + // Renew or create tenant package + $tenantPackage = TenantPackage::where('tenant_id', $tenantId) + ->where('package_id', $packageId) + ->where('stripe_subscription_id', $subscription) + ->first(); + + if ($tenantPackage) { + $tenantPackage->update([ + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + } else { + TenantPackage::create([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'stripe_subscription_id' => $subscription, + 'used_events' => 0, + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + } + + // Create purchase record + PackagePurchase::create([ + 'package_id' => $packageId, + 'type' => 'reseller_subscription', + 'provider_id' => 'stripe', + 'transaction_id' => $invoice['id'], + 'purchased_price' => $invoice['amount_paid'] / 100, + 'metadata' => $metadata, + ]); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 2c5c1d4..7b5d499 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -53,15 +53,18 @@ class EventController extends Controller $tenant = Tenant::findOrFail($tenantId); } - if ($tenant->event_credits_balance < 1) { + if (!$tenant->canCreateEvent()) { return response()->json([ - 'error' => 'Insufficient event credits. Please purchase more credits.', + 'error' => 'No available package for creating events. Please purchase a package.', ], 402); } $validated = $request->validated(); $tenantId = $tenant->id; + $packageId = $validated['package_id'] ?? 1; // Default to Free package ID 1 + unset($validated['package_id']); + $eventData = array_merge($validated, [ 'tenant_id' => $tenantId, 'status' => $validated['status'] ?? 'draft', @@ -116,24 +119,43 @@ class EventController extends Controller $eventData = Arr::only($eventData, $allowed); - $event = DB::transaction(function () use ($tenant, $eventData) { + $event = DB::transaction(function () use ($tenant, $eventData, $packageId) { $event = Event::create($eventData); - $note = sprintf('Event create: %s', $event->slug); - if (! $tenant->decrementCredits(1, 'event_create', $note, null)) { - throw new \RuntimeException('Unable to deduct credits'); + // Create EventPackage and PackagePurchase for Free package + $package = \App\Models\Package::findOrFail($packageId); + $eventPackage = \App\Models\EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $packageId, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + ]); + + \App\Models\PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'package_id' => $packageId, + 'provider_id' => 'free', + 'price' => $package->price, + 'type' => 'endcustomer_event', + 'metadata' => json_encode(['note' => 'Free package assigned on event creation']), + ]); + + if ($tenant->activeResellerPackage) { + $tenant->incrementUsedEvents(); } return $event; }); $tenant->refresh(); - $event->load(['eventType', 'tenant']); + $event->load(['eventType', 'tenant', 'eventPackage.package']); return response()->json([ 'message' => 'Event created successfully', 'data' => new EventResource($event), - 'balance' => $tenant->event_credits_balance, + 'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None', + 'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0, ], 201); } diff --git a/app/Http/Controllers/Api/TenantPackageController.php b/app/Http/Controllers/Api/TenantPackageController.php new file mode 100644 index 0000000..3295041 --- /dev/null +++ b/app/Http/Controllers/Api/TenantPackageController.php @@ -0,0 +1,36 @@ +attributes->get('tenant'); + + if (!$tenant) { + return response()->json(['error' => 'Tenant not found.'], 404); + } + + $packages = TenantPackage::where('tenant_id', $tenant->id) + ->with('package') + ->orderBy('created_at', 'desc') + ->get(); + + $packages->each(function ($package) { + $package->remaining_events = $package->package->max_events_per_year - $package->used_events; + $package->package_limits = $package->package->getAttributes(); // Or custom accessor for limits + }); + + return response()->json([ + 'data' => $packages, + 'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null, + 'message' => 'Tenant packages loaded successfully.', + ]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/OAuthController.php b/app/Http/Controllers/OAuthController.php index 940a37d..13802c4 100644 --- a/app/Http/Controllers/OAuthController.php +++ b/app/Http/Controllers/OAuthController.php @@ -153,9 +153,9 @@ class OAuthController extends Controller 'name' => $tenant->name, 'slug' => $tenant->slug, 'email' => $tenant->contact_email, - 'event_credits_balance' => $tenant->event_credits_balance, - 'subscription_tier' => $tenant->subscription_tier, - 'subscription_expires_at' => $tenant->subscription_expires_at, + 'active_reseller_package_id' => $tenant->active_reseller_package_id, + 'remaining_events' => $tenant->activeResellerPackage?->remaining_events ?? 0, + 'package_expires_at' => $tenant->activeResellerPackage?->expires_at, 'features' => $tenant->features, 'scopes' => Arr::get($decoded, 'scopes', []), ]); diff --git a/app/Http/Middleware/CreditCheckMiddleware.php b/app/Http/Middleware/CreditCheckMiddleware.php index 1b0054f..6296580 100644 --- a/app/Http/Middleware/CreditCheckMiddleware.php +++ b/app/Http/Middleware/CreditCheckMiddleware.php @@ -22,9 +22,9 @@ class CreditCheckMiddleware ]); } - if ($this->requiresCredits($request) && $tenant->event_credits_balance < 1) { + if ($this->requiresCredits($request) && !$tenant->canCreateEvent()) { return response()->json([ - 'error' => 'Insufficient event credits. Please purchase more credits.', + 'error' => 'No available package for creating events. Please purchase a package.', ], 402); } diff --git a/app/Models/Event.php b/app/Models/Event.php index 72ee775..75c54b8 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -50,4 +50,32 @@ class Event extends Model return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id') ->withTimestamps(); } + + public function eventPackage(): BelongsTo + { + return $this->belongsTo(EventPackage::class); + } + + public function hasActivePackage(): bool + { + return $this->eventPackage && $this->eventPackage->isActive(); + } + + public function getPackageLimits(): array + { + if (!$this->hasActivePackage()) { + return []; + } + + return $this->eventPackage->package->limits; + } + + public function canUploadPhoto(): bool + { + if (!$this->hasActivePackage()) { + return false; + } + + return $this->eventPackage->canUploadPhoto(); + } } diff --git a/app/Models/EventPackage.php b/app/Models/EventPackage.php new file mode 100644 index 0000000..4b279e0 --- /dev/null +++ b/app/Models/EventPackage.php @@ -0,0 +1,95 @@ + 'decimal:2', + 'purchased_at' => 'datetime', + 'gallery_expires_at' => 'datetime', + 'used_photos' => 'integer', + 'used_guests' => 'integer', + ]; + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function package(): BelongsTo + { + return $this->belongsTo(Package::class); + } + + public function isActive(): bool + { + return $this->gallery_expires_at && $this->gallery_expires_at->isFuture(); + } + + public function canUploadPhoto(): bool + { + if (!$this->isActive()) { + return false; + } + + $maxPhotos = $this->package->max_photos ?? 0; + return $this->used_photos < $maxPhotos; + } + + public function canAddGuest(): bool + { + if (!$this->isActive()) { + return false; + } + + $maxGuests = $this->package->max_guests ?? 0; + return $this->used_guests < $maxGuests; + } + + public function getRemainingPhotosAttribute(): int + { + $max = $this->package->max_photos ?? 0; + return max(0, $this->max_photos - $this->used_photos); + } + + public function getRemainingGuestsAttribute(): int + { + $max = $this->package->max_guests ?? 0; + return max(0, $this->max_guests - $this->used_guests); + } + + protected static function boot() + { + parent::boot(); + + static::creating(function ($eventPackage) { + if (!$eventPackage->purchased_at) { + $eventPackage->purchased_at = now(); + } + if (!$eventPackage->gallery_expires_at && $eventPackage->package) { + $days = $eventPackage->package->gallery_days ?? 30; + $eventPackage->gallery_expires_at = now()->addDays($days); + } + }); + } +} \ No newline at end of file diff --git a/app/Models/Package.php b/app/Models/Package.php new file mode 100644 index 0000000..2714f5b --- /dev/null +++ b/app/Models/Package.php @@ -0,0 +1,86 @@ + 'decimal:2', + 'max_photos' => 'integer', + 'max_guests' => 'integer', + 'gallery_days' => 'integer', + 'max_tasks' => 'integer', + 'max_events_per_year' => 'integer', + 'expires_after' => 'datetime', + 'watermark_allowed' => 'boolean', + 'branding_allowed' => 'boolean', + 'features' => 'array', + ]; + + protected function features(): Attribute + { + return Attribute::make( + get: fn (mixed $value) => $value ? json_decode($value, true) : [], + set: fn (array $value) => json_encode($value), + ); + } + + public function eventPackages(): HasMany + { + return $this->hasMany(EventPackage::class); + } + + public function tenantPackages(): HasMany + { + return $this->hasMany(TenantPackage::class); + } + + public function packagePurchases(): HasMany + { + return $this->hasMany(PackagePurchase::class); + } + + public function isEndcustomer(): bool + { + return $this->type === 'endcustomer'; + } + + public function isReseller(): bool + { + return $this->type === 'reseller'; + } + + public function getLimitsAttribute(): array + { + return [ + 'max_photos' => $this->max_photos, + 'max_guests' => $this->max_guests, + 'gallery_days' => $this->gallery_days, + 'max_tasks' => $this->max_tasks, + 'max_events_per_year' => $this->max_events_per_year, + ]; + } +} \ No newline at end of file diff --git a/app/Models/PackagePurchase.php b/app/Models/PackagePurchase.php new file mode 100644 index 0000000..35eb569 --- /dev/null +++ b/app/Models/PackagePurchase.php @@ -0,0 +1,87 @@ + 'decimal:2', + 'purchased_at' => 'datetime', + 'metadata' => 'array', + 'refunded' => 'boolean', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function package(): BelongsTo + { + return $this->belongsTo(Package::class); + } + + public function isEndcustomerEvent(): bool + { + return $this->type === 'endcustomer_event'; + } + + public function isResellerSubscription(): bool + { + return $this->type === 'reseller_subscription'; + } + + public function isRefunded(): bool + { + return $this->refunded; + } + + public function getMetadataAttribute($value) + { + return $value ? json_decode($value, true) : []; + } + + public function setMetadataAttribute($value) + { + $this->attributes['metadata'] = is_array($value) ? json_encode($value) : $value; + } + + protected static function boot() + { + parent::boot(); + + static::creating(function ($purchase) { + if (!$purchase->purchased_at) { + $purchase->purchased_at = now(); + } + $purchase->refunded = false; + }); + } +} \ No newline at end of file diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index c1f3305..9ea32e0 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -20,9 +20,6 @@ class Tenant extends Model 'features' => 'array', 'settings' => 'array', 'last_activity_at' => 'datetime', - 'event_credits_balance' => 'integer', - 'subscription_tier' => 'string', - 'subscription_expires_at' => 'datetime', 'total_revenue' => 'decimal:2', 'settings_updated_at' => 'datetime', ]; @@ -46,17 +43,38 @@ class Tenant extends Model public function purchases(): HasMany { - return $this->hasMany(PurchaseHistory::class); + return $this->hasMany(PackagePurchase::class); } - public function eventPurchases(): HasMany + public function tenantPackages(): HasMany { - return $this->hasMany(EventPurchase::class); + return $this->hasMany(TenantPackage::class); } - public function creditsLedger(): HasMany + public function activeResellerPackage() { - return $this->hasMany(EventCreditsLedger::class); + return $this->tenantPackages()->where('active', true)->first(); + } + + public function canCreateEvent(): bool + { + $package = $this->activeResellerPackage(); + if (!$package) { + return false; + } + + return $package->canCreateEvent(); + } + + public function incrementUsedEvents(int $amount = 1): bool + { + $package = $this->activeResellerPackage(); + if (!$package) { + return false; + } + + $package->increment('used_events', $amount); + return true; } public function setSettingsAttribute($value): void @@ -72,88 +90,7 @@ class Tenant extends Model public function activeSubscription(): Attribute { return Attribute::make( - get: fn () => $this->subscription_expires_at && $this->subscription_expires_at->isFuture(), + get: fn () => $this->activeResellerPackage() !== null, ); } - - public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool - { - if ($amount <= 0) { - return true; - } - - $operation = function () use ($amount, $reason, $note, $relatedPurchaseId) { - $locked = static::query() - ->whereKey($this->getKey()) - ->lockForUpdate() - ->first(); - - if (! $locked || $locked->event_credits_balance < $amount) { - return false; - } - - EventCreditsLedger::create([ - 'tenant_id' => $this->id, - 'delta' => -$amount, - 'reason' => $reason, - 'related_purchase_id' => $relatedPurchaseId, - 'note' => $note, - ]); - - $locked->event_credits_balance -= $amount; - $locked->save(); - - $this->event_credits_balance = $locked->event_credits_balance; - - return true; - }; - - return $this->runCreditOperation($operation); - } - - public function incrementCredits(int $amount, string $reason = 'manual_adjust', ?string $note = null, ?int $relatedPurchaseId = null): bool - { - if ($amount <= 0) { - return true; - } - - $operation = function () use ($amount, $reason, $note, $relatedPurchaseId) { - $locked = static::query() - ->whereKey($this->getKey()) - ->lockForUpdate() - ->first(); - - if (! $locked) { - return false; - } - - EventCreditsLedger::create([ - 'tenant_id' => $this->id, - 'delta' => $amount, - 'reason' => $reason, - 'related_purchase_id' => $relatedPurchaseId, - 'note' => $note, - ]); - - $locked->event_credits_balance += $amount; - $locked->save(); - - $this->event_credits_balance = $locked->event_credits_balance; - - return true; - }; - - return $this->runCreditOperation($operation); - } - - private function runCreditOperation(callable $operation): bool - { - $connection = DB::connection(); - - if ($connection->transactionLevel() > 0) { - return (bool) $operation(); - } - - return (bool) $connection->transaction($operation); - } } diff --git a/app/Models/TenantPackage.php b/app/Models/TenantPackage.php new file mode 100644 index 0000000..67f5412 --- /dev/null +++ b/app/Models/TenantPackage.php @@ -0,0 +1,93 @@ + 'decimal:2', + 'purchased_at' => 'datetime', + 'expires_at' => 'datetime', + 'used_events' => 'integer', + 'active' => 'boolean', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function package(): BelongsTo + { + return $this->belongsTo(Package::class); + } + + public function isActive(): bool + { + return $this->active && (!$this->expires_at || $this->expires_at->isFuture()); + } + + public function canCreateEvent(): bool + { + if (!$this->isActive()) { + return false; + } + + if (!$this->package->isReseller()) { + return false; + } + + $maxEvents = $this->package->max_events_per_year ?? 0; + return $this->used_events < $maxEvents; + } + + public function getRemainingEventsAttribute(): int + { + if (!$this->package->isReseller()) { + return 0; + } + + $max = $this->package->max_events_per_year ?? 0; + return max(0, $max - $this->used_events); + } + + protected static function boot() + { + parent::boot(); + + static::creating(function ($tenantPackage) { + if (!$tenantPackage->purchased_at) { + $tenantPackage->purchased_at = now(); + } + if (!$tenantPackage->expires_at && $tenantPackage->package) { + $tenantPackage->expires_at = now()->addYear(); // Standard für Reseller + } + $tenantPackage->active = true; + }); + + static::updating(function ($tenantPackage) { + if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) { + $tenantPackage->active = false; + } + }); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index 388ea39..923c5e3 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-translatable": "^6.11", "stephenjude/filament-blog": "*", - "stripe/stripe-php": "^17.6" + "stripe/stripe-php": "*" }, "require-dev": { "fakerphp/faker": "^1.23", diff --git a/composer.lock b/composer.lock index bd9838a..9a3fbb3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a79b02d59d8ee7716beea2fc8442a905", + "content-hash": "cb0adb8c2149ab0ab72bdc3b0b7ee635", "packages": [ { "name": "anourvalar/eloquent-serialize", diff --git a/config/services.php b/config/services.php index ec700e2..d996a58 100644 --- a/config/services.php +++ b/config/services.php @@ -14,6 +14,13 @@ return [ | */ + 'mailgun' => [ + 'domain' => env('MAILGUN_DOMAIN'), + 'secret' => env('MAILGUN_SECRET'), + 'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'), + 'scheme' => 'https', + ], + 'postmark' => [ 'token' => env('POSTMARK_TOKEN'), ], @@ -24,26 +31,10 @@ return [ 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), ], - 'resend' => [ - 'key' => env('RESEND_KEY'), - ], - - 'slack' => [ - 'notifications' => [ - 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), - 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), - ], - ], - 'stripe' => [ + 'model' => App\Models\User::class, 'key' => env('STRIPE_KEY'), 'secret' => env('STRIPE_SECRET'), - 'webhook' => env('STRIPE_WEBHOOK_SECRET'), ], - 'revenuecat' => [ - 'webhook' => env('REVENUECAT_WEBHOOK_SECRET'), - 'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''), - 'app_user_prefix' => env('REVENUECAT_APP_USER_PREFIX', 'tenant'), - ], ]; diff --git a/database/migrations/2025_09_26_190940_create_packages_table.php b/database/migrations/2025_09_26_190940_create_packages_table.php new file mode 100644 index 0000000..63d9286 --- /dev/null +++ b/database/migrations/2025_09_26_190940_create_packages_table.php @@ -0,0 +1,40 @@ +id(); + $table->string('name'); + $table->enum('type', ['endcustomer', 'reseller']); + $table->decimal('price', 8, 2); + $table->integer('max_photos')->nullable(); + $table->integer('max_guests')->nullable(); + $table->integer('gallery_days')->nullable(); + $table->integer('max_tasks')->nullable(); + $table->boolean('watermark_allowed')->default(true); + $table->boolean('branding_allowed')->default(false); + $table->integer('max_events_per_year')->nullable(); + $table->timestamp('expires_after')->nullable(); + $table->json('features')->nullable(); + $table->timestamps(); + $table->index(['type', 'price']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('packages'); + } +}; diff --git a/database/migrations/2025_09_26_191007_create_event_packages_table.php b/database/migrations/2025_09_26_191007_create_event_packages_table.php new file mode 100644 index 0000000..579b54e --- /dev/null +++ b/database/migrations/2025_09_26_191007_create_event_packages_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('purchased_price', 8, 2); + $table->timestamp('purchased_at'); + $table->integer('used_photos')->default(0); + $table->timestamps(); + $table->index('event_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_packages'); + } +}; diff --git a/database/migrations/2025_09_26_191026_create_tenant_packages_table.php b/database/migrations/2025_09_26_191026_create_tenant_packages_table.php new file mode 100644 index 0000000..a9c1e3a --- /dev/null +++ b/database/migrations/2025_09_26_191026_create_tenant_packages_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('price', 8, 2); + $table->timestamp('purchased_at'); + $table->timestamp('expires_at'); + $table->integer('used_events')->default(0); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->index(['tenant_id', 'active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('tenant_packages'); + } +}; diff --git a/database/migrations/2025_09_26_191046_create_package_purchases_table.php b/database/migrations/2025_09_26_191046_create_package_purchases_table.php new file mode 100644 index 0000000..627705f --- /dev/null +++ b/database/migrations/2025_09_26_191046_create_package_purchases_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('tenant_id')->nullable()->constrained(); + $table->foreignId('event_id')->nullable()->constrained(); + $table->foreignId('package_id')->constrained(); + $table->string('provider_id'); + $table->decimal('price', 8, 2); + $table->enum('type', ['endcustomer_event', 'reseller_subscription']); + $table->json('metadata')->nullable(); + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->boolean('refunded')->default(false); + $table->timestamps(); + $table->index(['tenant_id', 'purchased_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('package_purchases'); + } +}; diff --git a/database/migrations/2025_09_26_191104_drop_credits_tables_and_fields.php b/database/migrations/2025_09_26_191104_drop_credits_tables_and_fields.php new file mode 100644 index 0000000..09aeb35 --- /dev/null +++ b/database/migrations/2025_09_26_191104_drop_credits_tables_and_fields.php @@ -0,0 +1,70 @@ +dropColumn([ + 'event_credits_balance', + 'subscription_tier', + 'subscription_expires_at', + 'free_event_granted_at', + 'total_revenue' + ]); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->integer('event_credits_balance')->default(1); + $table->string('subscription_tier')->nullable(); + $table->timestamp('subscription_expires_at')->nullable(); + $table->timestamp('free_event_granted_at')->nullable(); + $table->decimal('total_revenue', 10, 2)->default(0.00); + }); + + Schema::create('event_purchases', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->integer('credits_added')->default(0); + $table->decimal('price', 10, 2)->default(0); + $table->string('provider_id'); + $table->timestamps(); + }); + + Schema::create('purchase_history', function (Blueprint $table) { + $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(); + }); + + Schema::create('event_credits_ledger', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->integer('credits_change'); + $table->string('reason'); + $table->timestamps(); + }); + } +}; diff --git a/database/migrations/2025_09_26_191137_migrate_credits_to_packages.php b/database/migrations/2025_09_26_191137_migrate_credits_to_packages.php new file mode 100644 index 0000000..5d888d6 --- /dev/null +++ b/database/migrations/2025_09_26_191137_migrate_credits_to_packages.php @@ -0,0 +1,152 @@ +count() == 0) { + // Insert standard packages if not seeded + DB::table('packages')->insert([ + [ + 'name' => 'Free/Test', + 'type' => 'endcustomer', + 'price' => 0.00, + 'max_photos' => 30, + 'max_guests' => 10, + 'gallery_days' => 3, + 'max_tasks' => 1, + 'watermark_allowed' => true, + 'branding_allowed' => false, + 'max_events_per_year' => null, + 'expires_after' => null, + 'features' => json_encode([]), + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'Starter', + 'type' => 'endcustomer', + 'price' => 19.00, + 'max_photos' => 300, + 'max_guests' => 50, + 'gallery_days' => 14, + 'max_tasks' => 5, + 'watermark_allowed' => true, + 'branding_allowed' => false, + 'max_events_per_year' => null, + 'expires_after' => null, + 'features' => json_encode([]), + 'created_at' => now(), + 'updated_at' => now(), + ], + // Add more standard packages as per plan + [ + 'name' => 'Reseller S', + 'type' => 'reseller', + 'price' => 149.00, + 'max_photos' => null, + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'max_events_per_year' => 5, + 'expires_after' => now()->addYear(), + 'features' => json_encode(['limited_branding']), + 'created_at' => now(), + 'updated_at' => now(), + ], + // ... other reseller packages + ]); + } + + // Migrate tenant credits to tenant_packages (Free package) + DB::table('tenants')->where('event_credits_balance', '>', 0)->chunk(100, function ($tenants) { + foreach ($tenants as $tenant) { + $freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id; + DB::table('tenant_packages')->insert([ + 'tenant_id' => $tenant->id, + 'package_id' => $freePackageId, + 'price' => 0.00, + 'purchased_at' => $tenant->free_event_granted_at ?? now(), + 'expires_at' => now()->addDays(30), // or based on credits + 'used_events' => min($tenant->event_credits_balance, 1), // e.g. 1 free event + 'active' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Create purchase ledger entry + DB::table('package_purchases')->insert([ + '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 to event_packages (if any existing events) + DB::table('events')->chunk(100, function ($events) { + foreach ($events as $event) { + if ($event->tenant->event_credits_balance > 0) { // or check if event was created with credits + $freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id; + DB::table('event_packages')->insert([ + 'event_id' => $event->id, + 'package_id' => $freePackageId, + 'purchased_price' => 0.00, + 'purchased_at' => $event->created_at, + 'used_photos' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Ledger entry + DB::table('package_purchases')->insert([ + 'tenant_id' => $event->tenant_id, + 'event_id' => $event->id, + 'package_id' => $freePackageId, + 'provider_id' => 'migration_free', + 'price' => 0.00, + 'type' => 'endcustomer_event', + 'metadata' => json_encode(['migrated_from_credits' => true]), + 'ip_address' => null, + 'user_agent' => null, + 'refunded' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('packages', function (Blueprint $table) { + // + }); + } +}; diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php new file mode 100644 index 0000000..4091ec1 --- /dev/null +++ b/database/seeders/PackageSeeder.php @@ -0,0 +1,138 @@ + 'Free / Test', + 'type' => 'endcustomer', + 'price' => 0.00, + 'max_photos' => 30, + 'max_guests' => 10, + 'gallery_days' => 3, + 'max_tasks' => 5, + 'watermark_allowed' => false, + 'branding_allowed' => false, + 'features' => json_encode([ + 'basic_uploads' => true, + 'limited_sharing' => true, + 'no_branding' => true, + ]), + 'description' => 'Ideal für kleine Test-Events oder erste Erfahrungen.', + ]); + + Package::create([ + 'name' => 'Starter', + 'type' => 'endcustomer', + 'price' => 19.00, + 'max_photos' => 300, + 'max_guests' => 50, + 'gallery_days' => 14, + 'max_tasks' => 20, + 'watermark_allowed' => true, + 'branding_allowed' => false, + 'features' => json_encode([ + 'extended_gallery' => true, + 'guest_sharing' => true, + 'basic_analytics' => true, + ]), + 'description' => 'Perfekt für kleine Events wie Geburtstage oder Firmenfeiern.', + ]); + + Package::create([ + 'name' => 'Pro', + 'type' => 'endcustomer', + 'price' => 49.00, + 'max_photos' => 1000, + 'max_guests' => 200, + 'gallery_days' => 30, + 'max_tasks' => 50, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'features' => json_encode([ + 'unlimited_sharing' => true, + 'advanced_analytics' => true, + 'custom_branding' => true, + 'priority_support' => true, + ]), + 'description' => 'Für große Events wie Hochzeiten oder Konferenzen.', + ]); + + // Reseller Packages (jährliche Subscriptions) + Package::create([ + 'name' => 'Reseller S', + 'type' => 'reseller', + 'price' => 149.00, + 'max_events_per_year' => 5, + 'max_photos' => null, // Kein globales Limit, pro Event + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'expires_after' => now()->addYear(), // Jährlich + 'features' => json_encode([ + 'event_management' => true, + 'reseller_dashboard' => true, + 'bulk_event_creation' => true, + '5_events_included' => true, + ]), + 'description' => 'Einstieg für kleine Agenturen: 5 Events pro Jahr.', + ]); + + Package::create([ + 'name' => 'Reseller M', + 'type' => 'reseller', + 'price' => 299.00, + 'max_events_per_year' => 15, + 'max_photos' => null, + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'expires_after' => now()->addYear(), + 'features' => json_encode([ + 'event_management' => true, + 'reseller_dashboard' => true, + 'bulk_event_creation' => true, + 'advanced_reporting' => true, + '15_events_included' => true, + ]), + 'description' => 'Für wachsende Agenturen: 15 Events pro Jahr.', + ]); + + Package::create([ + 'name' => 'Reseller L', + 'type' => 'reseller', + 'price' => 499.00, + 'max_events_per_year' => 30, + 'max_photos' => null, + 'max_guests' => null, + 'gallery_days' => null, + 'max_tasks' => null, + 'watermark_allowed' => true, + 'branding_allowed' => true, + 'expires_after' => now()->addYear(), + 'features' => json_encode([ + 'event_management' => true, + 'reseller_dashboard' => true, + 'bulk_event_creation' => true, + 'priority_support' => true, + 'custom_integration' => true, + '30_events_included' => true, + ]), + 'description' => 'Für große Agenturen: 30 Events pro Jahr mit Premium-Features.', + ]); + } +} diff --git a/docs/packages-business-model-plan.md b/docs/packages-business-model-plan.md new file mode 100644 index 0000000..bad237d --- /dev/null +++ b/docs/packages-business-model-plan.md @@ -0,0 +1,241 @@ +# Fotospiel: Umstellung auf Package-basiertes Business Model – Detaillierter Plan + +**Datum:** 2025-09-26 +**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. + +## 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), PayPalWebhookController (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, PayPal untergenutzt. + +**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen. + +## 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. + +### Endkunden-Pakete (Einmalkäufe pro Event) +| Paket | Preis | max_photos | max_guests | gallery_days | max_tasks | watermark | branding | Features | +|-----------|-------|------------|------------|--------------|-----------|-----------|----------|----------| +| Free/Test | 0 € | 30 | 10 | 3 | 1 | Standard | Nein | - | +| Starter | 19 € | 300 | 50 | 14 | 5 | Standard | Nein | - | +| Standard | 39 € | 1000 | 150 | 30 | 10 | Custom | Ja | Logo | +| Premium | 79 € | 3000 | 500 | 180 | 20 | Kein | Ja | Live-Slideshow, Analytics | + +### Reseller/Agentur-Pakete (Jährliche Subscriptions) +| Paket | Preis/Jahr | max_events/year | Per-Event Limits | Branding | Extra | +|------------|------------|-----------------|------------------|----------|-------| +| Reseller S | 149 € | 5 | Standard | Eingeschränkt | - | +| Reseller M | 299 € | 15 | Standard | Eigene Logos | 3 Monate Galerie | +| Reseller L | 599 € | 40 | Premium | White-Label | - | +| Enterprise | ab 999 € | Unlimited | Premium | Voll | Custom Domain, Support | + +**Flow:** Event-Create: Package wählen → Kauf (Free: direkt; Paid: Checkout) → Limits für Event setzen. Reseller: Tenant-Package limitiert Events/Features global. + +## 3. DB-Schema & Migrationen +### Neue Tabellen (Migration: create_packages_tables.php) +- **packages (global):** + ```php + $table->id(); + $table->string('name'); + $table->enum('type', ['endcustomer', 'reseller']); + $table->decimal('price', 8, 2); + $table->integer('max_photos')->nullable(); + $table->integer('max_guests')->nullable(); + $table->integer('gallery_days')->nullable(); + $table->integer('max_tasks')->nullable(); + $table->boolean('watermark_allowed')->default(true); + $table->boolean('branding_allowed')->default(false); + $table->integer('max_events_per_year')->nullable(); + $table->timestamp('expires_after')->nullable(); // Für Subscriptions + $table->json('features')->nullable(); // ['live_slideshow', 'analytics'] + $table->timestamps(); + $table->index(['type', 'price']); // Für Queries + ``` +- **event_packages (pro Event):** + ```php + $table->id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('purchased_price', 8, 2); + $table->timestamp('purchased_at'); + $table->integer('used_photos')->default(0); // Counter + $table->timestamps(); + $table->index('event_id'); + ``` +- **tenant_packages (Reseller):** + ```php + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('price', 8, 2); + $table->timestamp('purchased_at'); + $table->timestamp('expires_at'); + $table->integer('used_events')->default(0); + $table->boolean('active')->default(true); + $table->timestamps(); + $table->index(['tenant_id', 'active']); + ``` +- **package_purchases (Ledger):** + ```php + $table->id(); + $table->foreignId('tenant_id')->nullable()->constrained(); + $table->foreignId('event_id')->nullable()->constrained(); + $table->foreignId('package_id')->constrained(); + $table->string('provider_id'); // Stripe/PayPal ID + $table->decimal('price', 8, 2); + $table->enum('type', ['endcustomer_event', 'reseller_subscription']); + $table->json('metadata'); // {event_id, ip_address} + $table->string('ip_address')->nullable(); + $table->string('user_agent')->nullable(); + $table->boolean('refunded')->default(false); + $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). + - 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. +- **Performance:** Transactions für Migration; Cache::flush() nach. + +## 4. Filament 4 Resources (Backend-Logik, Todo 6) +- **PackageResource (app/Filament/Resources/PackageResource.php, SuperAdmin):** + - Form: TextInput('name'), Select('type'), MoneyInput('price'), NumericInputs für Limits, Toggles für watermark/branding, Repeater('features'), Numeric('max_events_per_year'). + - Table: TextColumn('name'), BadgeColumn('type'), MoneyColumn('price'), IconColumn('limits' – z.B. CameraIcon für max_photos), Actions (Edit/Delete/Duplicate). + - Pages: ListPackages, CreatePackage, EditPackage. + - Policy: SuperAdmin only. + +- **TenantPackageResource (SuperAdmin/TenantAdmin):** + - Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active'). + - Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Stripe/PayPal cancel). + - Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List). + - Bulk-Actions: Renew Selected. + +- **PurchaseResource (SuperAdmin/TenantAdmin):** + - Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded'). + - Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Stripe/PayPal API, decrement counters, log). + - Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter. + - Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts). + - Export: CSV (für Buchhaltung: tenant, package, price, date). + +**Integration:** Ersetze add_credits in TenantResource durch 'Assign Package'-Action (modal mit Select + Intent-Call). Policies: Role-based (superadmin full, tenant_admin own). + +## 5. Marketing- und Legal-Anpassungen (Todo 4) +- **Webfrontend (Blade, resources/views/marketing/):** + - **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package). + - **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Stripe/PayPal), Stripe-Element/PayPal-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges. + - **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link. + - **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages. + - **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ. + +- **Legal (resources/views/legal/):** + - **datenschutz.blade.php:** Abschnitt "Zahlungen" (Stripe/PayPal: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert." + - **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de". + - **Allgemein:** Datum "Aktualisiert: 2025-09-26 – Package-Modell"; Links zu Provider-Datenschutz. + +**i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket'). + +## 6. Backend-Logik & API (Todo 6/7) +- **Controllers:** + - `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf). + - `PurchasesController` (intent: Erstelle Stripe-Session oder PayPal-Order basierend auf method; store: Nach Webhook). +- **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year). +- **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false). +- **API-Endpunkte (routes/api.php, tenant-group):** + - GET /packages (Liste, filter by type). + - GET /packages/{id} (Details). + - POST /packages/purchase (Body: package_id, type, event_id?; Response: {checkout_url, provider}). + - GET /tenant/packages (Active Package, Purchases-List). + - POST /tenant/packages/assign (Free-Zuweisung). + - DELETE /credits/* (entfernen, 404-redirect). + - Tokens: Füge 'package_info' (JSON: active_package_id) zu JWT-Claims hinzu (via Sanctum). +- **Jobs:** `ProcessPackagePurchase` (nach Webhook: Zuweisen, E-Mail, Analytics-Event). + +## 7. Frontend-Anpassungen (Todo 8/9) +- **Admin PWA (resources/js/admin/):** + - EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Stripe/PayPal-Integration (stripe.elements oder PayPal-Button). + - Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button). + - SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button). + - Hooks: usePackageLimits (fetch /packages, check used_photos). +- **Guest PWA (resources/js/guest/):** + - EventDetailPage.tsx: Header "Package: Premium – {used_photos}/{max_photos} Fotos, Galerie bis {date}". + - Upload-Component: If used_photos >= max_photos → Disable + Message "Limit erreicht – Upgrade via Admin". + - Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed. + - Router: Guard für Limits (z.B. /upload → Check API). + +**Tech:** React Query für API-Calls, Stripe.js/PayPal-SDK in Components, i18n mit react-i18next. + +## 8. Billing-Integration (Todo 10) +- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (Alternative: PHP SDK für Orders/Subscriptions). +- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → PayPal::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail). +- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase). +- **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js. +- **Free:** Kein Provider – direkt assign via API. +- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder PayPal::refunds, decrement Counters. +- **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags. + +## 9. Tests (Todo 11) +- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Stripe/PayPal SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded). +- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose PayPal, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max). +- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422). +- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation. + +## 10. Deployment & Rollout (Todo 12) +- **Vorbereitung:** Backup DB (php artisan db:backup), Staging-Env (duplicate prod, test Migration). +- **Schritte:** + 1. Deploy Migration/Seeder (php artisan migrate, db:seed --class=PackageSeeder). + 2. Run packages:migrate (Command: Transfer Daten, log Errors). + 3. Update Code (Controllers/Middleware/Resources, API-Routes). + 4. Frontend-Build (npm run build for PWAs). + 5. Smoke-Tests (Kauf-Flow, Limits, Webhooks mit Sandbox). + 6. Go-Live: Feature-Flag (config/packages.enabled = true), Monitor mit Telescope/Sentry. +- **Rollback:** migrate:rollback, restore Backup. +- **Post-Deployment:** Update TODO.md (neue Tasks: Monitor Conversions), Gogs-Issues (z.B. "Implement Package Analytics"), E-Mail an Users ("Neues Package-Modell – Ihr Free-Paket ist aktiv"). +- **Monitoring:** Scheduled Job (daily: Check expired Packages, notify), Revenue-Dashboard in Filament. + +## 11. Identifizierte Lücken & Best Practices +- **Sicherheit:** PCI-Compliance (Provider-handhabt), Audit-Logs (payments-channel), Rate-Limiting (/checkout: 5/min), GDPR (Lösch-Job, Consent in Checkout). +- **i18n:** Package-Features als translatable JSON, Locale in Checkout (Stripe metadata). +- **Analytics:** GA-Events in Frontend, Telescope für Backend-Käufe, ARPU-Tracking in Widgets. +- **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf. +- **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob). +- **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync). +- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe). +- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts. + +## 12. Todo-List (Status: Alle Planung completed) +- [x] Analyse. +- [x] Design (15-packages-design.md). +- [x] PRP-Updates. +- [x] Marketing/Legal (Blades mit Checkout). +- [x] DB-Migrationen. +- [ ] Backend (Resources/Controllers). +- [ ] API. +- [ ] PWAs. +- [ ] Billing (SDKs/Webhooks). +- [ ] Tests. +- [ ] Deployment. + +**Nächster Schritt:** Wechsel zu Code-Mode für Implementation (start with DB-Migrationen). Kontaktieren Sie für Änderungen. \ No newline at end of file diff --git a/docs/prp/08-billing.md b/docs/prp/08-billing.md index 58cf2f3..22d3516 100644 --- a/docs/prp/08-billing.md +++ b/docs/prp/08-billing.md @@ -1,7 +1,7 @@ -# 08 — Billing (MVP: Event Credits) +# 08 — Billing (Packages) -- Model: one-off purchases that grant event credits; no subscriptions in MVP. -- Tables: `event_purchases`, `event_credits_ledger` (see 04-data-model-migrations.md). -- Providers: Stripe (server-side checkout + webhooks); store receipts deferred. -- Idempotency: purchase intents keyed; ledger writes idempotent; retries safe. -- Limits: enforce `event_credits_balance >= 1` to create an event; ledger decrements on event creation. +- Model: one-off purchases of event packages (Endkunden) or annual subscriptions (Reseller); see 15-packages-design.md for details. +- Tables: `packages`, `event_packages`, `tenant_packages`, `package_purchases` (see 04-data-model-migrations.md and 15-packages-design.md). +- Providers: Stripe (server-side checkout + webhooks for Einmalkäufe/Subscriptions); store receipts. +- Idempotency: purchase intents keyed; purchase writes idempotent; retries safe. +- Limits: Enforce package selection at event creation; check event-specific limits (e.g. max_photos) during usage; tenant limits for reseller event count. diff --git a/docs/prp/14-freemium-business-model.md b/docs/prp/14-freemium-business-model.md index 44e0ee4..968dbce 100644 --- a/docs/prp/14-freemium-business-model.md +++ b/docs/prp/14-freemium-business-model.md @@ -2,7 +2,7 @@ ## Executive Summary -This document details the Freemium business model for the Fotospiel tenant app, combining free access with in-app purchases for event credits. The model prioritizes user acquisition through a free app download while monetizing through value-driven upgrades. Key metrics: 5-10% conversion rate, ARPU €10-15, scalable to 100k+ users. +This document details the Package-based business model for the Fotospiel tenant app, combining free access with purchases of predefined packages. The model prioritizes user acquisition through a free test package while monetizing through value-driven upgrades. Key metrics: 5-10% conversion rate, ARPU €10-15, scalable to 100k+ users. See 15-packages-design.md for package details. ## Model Analysis @@ -30,18 +30,13 @@ This document details the Freemium business model for the Fotospiel tenant app, - Complex IAP setup and testing - Requires strong onboarding to drive conversions -### Hybrid Freemium Recommendation -**Core Strategy:** Free app with limited first event (50 photos, basic features), unlimited upgrades via IAP credits/subscriptions. +### Package-based Recommendation +**Core Strategy:** Free app with limited test package for first event, upgrades via package purchases (Einmalkäufe for Endkunden, Subscriptions for Reseller). -**Pricing Structure:** -- **Free Tier:** 1 basic event (50 photos, standard tasks, no custom branding) -- **Consumable Credits:** - - Starter Pack: €4.99 for 5 events (100 photos each) - - Pro Pack: €14.99 for 20 events (unlimited photos) -- **Subscriptions:** - - Pro Unlimited: €4.99/month (all features, unlimited events) - - Agency: €19.99/month (multi-tenant, analytics, white-label) -- **Non-Consumables:** Lifetime Unlimited: €49.99 (one-time purchase) +**Pricing Structure:** See 15-packages-design.md for Endkunden (pro Event: Free/Test 0€, Starter 19€, etc.) and Reseller (jährlich: S 149€, etc.). +- **Free Tier:** Test package (30 photos, 10 guests, 3 days gallery, 1 task, standard watermark) +- **Endkunden Packages:** Einmalkäufe pro Event with increasing limits/features. +- **Reseller Packages:** Annual subscriptions with event limits and branding options. **Expected Metrics:** - Downloads: 50k/year @@ -91,38 +86,13 @@ This document details the Freemium business model for the Fotospiel tenant app, - **Analytics:** Firebase for funnel tracking, RevenueCat for purchase events ### Backend API Extensions -- **Credit Management:** `/api/v1/tenant/credits` endpoints -- **Purchase Validation:** Webhook receiver from RevenueCat +- **Package Management:** `/api/v1/packages` and `/api/v1/tenant/packages` endpoints +- **Purchase Validation:** Webhook receiver from Stripe - **Event Limiting:** Middleware checking credit balance before creation - **Subscription Sync:** Real-time updates via WebSockets (optional) ### Database Schema Additions -```sql --- tenant_credits table -CREATE TABLE tenant_credits ( - tenant_id VARCHAR(255) PRIMARY KEY, - balance INTEGER DEFAULT 1, -- 1 free event - total_purchased INTEGER DEFAULT 0, - subscription_active BOOLEAN DEFAULT FALSE, - subscription_tier VARCHAR(50), - last_sync TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP -); - --- purchase_history table -CREATE TABLE purchase_history ( - id VARCHAR(255) PRIMARY KEY, - tenant_id VARCHAR(255) NOT NULL, - package_id VARCHAR(255) NOT NULL, - credits_added INTEGER, - price DECIMAL(10,2), - currency VARCHAR(3), - platform VARCHAR(50), -- 'ios' or 'android' - transaction_id VARCHAR(255), - purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (tenant_id) REFERENCES tenants(id) -); -``` +See 15-packages-design.md for updated schema: `packages`, `event_packages`, `tenant_packages`, `package_purchases`. ## Risk Mitigation diff --git a/docs/prp/15-packages-design.md b/docs/prp/15-packages-design.md new file mode 100644 index 0000000..cccb436 --- /dev/null +++ b/docs/prp/15-packages-design.md @@ -0,0 +1,115 @@ +# Packages-Design für Fotospiel + +## Überblick +Dieses Dokument definiert das neue package-basierte Business Model, das das bestehende Credits-System ersetzt. Packages sind vordefinierte Bündel mit Limits und Features, die als Einmalkäufe pro Event (für Endkunden) oder jährliche Subscriptions (für Reseller/Agenturen) verkauft werden. Das Modell priorisiert Einfachheit: Bei Event-Erstellung wählt der User ein Package, das Limits für diesen Event setzt. Für Reseller limitiert das Tenant-Package die Anzahl Events pro Jahr und globale Features. + +Ziele: +- Ersetze Credits vollständig (keine Balance mehr, sondern Event-spezifische Limits). +- Unterstütze Freemium: Free-Paket für Einstieg. +- Skalierbar: Endkunden pro Event, Reseller jährlich. +- Integration: Stripe für Zahlungen (Einmalkäufe/Subscriptions), Ledger für Transaktionen. + +## Endkunden-Pakete (pro Event, Einmalkauf) +Diese Pakete werden bei Event-Erstellung ausgewählt und gekauft. Sie definieren Limits für den spezifischen Event (z.B. max_photos, gallery_duration). Preise basierend auf User-Vorschlag. + +| Paket | Preis | max_photos | max_guests | gallery_days | max_tasks | watermark | branding | Extra Features | +|-----------|-------|------------|------------|--------------|-----------|-----------|----------|----------------| +| Free/Test | 0 € | 30 | 10 | 3 | 1 | Standard | Nein | - | +| Starter | 19 € | 300 | 50 | 14 | 5 | Standard | Nein | - | +| Standard | 39 € | 1000 | 150 | 30 | 10 | Custom | Ja | Logo | +| Premium | 79 € | 3000 | 500 | 180 | 20 | Kein | Ja | Live-Slideshow, Analytics | + +## Reseller/Agentur-Pakete (jährlich, Subscription) +Diese Pakete werden auf Tenant-Ebene gekauft und limitieren Events pro Jahr, mit erweiterten Features (z.B. White-Label). Preise als Richtwerte. + +| Paket | Preis/Jahr | max_events/year | Per-Event Limits | Branding | Extra | +|------------|------------|-----------------|------------------|----------|-------| +| Reseller S | 149 € | 5 | Standard | Eingeschränkt | - | +| Reseller M | 299 € | 15 | Standard | Eigene Logos | 3 Monate Galerie | +| Reseller L | 599 € | 40 | Premium | White-Label | - | +| Enterprise | ab 999 € | Unlimited | Premium | Voll | Custom Domain, Support | + +## Datenbank-Schema +### Globale Packages-Tabelle (für alle Pakete, geteilt) +```php +Schema::create('packages', function (Blueprint $table) { + $table->id(); + $table->string('name'); // z.B. 'Starter', 'Reseller M' + $table->string('type'); // 'endcustomer' oder 'reseller' + $table->decimal('price', 8, 2); // Preis in EUR + $table->integer('max_photos')->nullable(); // Null für Reseller (vererbt an Events) + $table->integer('max_guests')->nullable(); + $table->integer('gallery_days')->nullable(); + $table->integer('max_tasks')->nullable(); + $table->boolean('watermark_allowed')->default(true); + $table->boolean('branding_allowed')->default(false); + $table->integer('max_events_per_year')->nullable(); // Für Reseller + $table->timestamp('expires_after')->nullable(); // Für Subscriptions + $table->json('features')->nullable(); // z.B. ['live_slideshow', 'analytics'] + $table->timestamps(); +}); +``` +Seeder: Füge die obigen Pakete ein. + +### Event-Packages (Zuordnung Event zu Endkunden-Paket) +```php +Schema::create('event_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('purchased_price', 8, 2); + $table->timestamp('purchased_at'); + $table->integer('used_photos')->default(0); // Counter für Limits + $table->timestamps(); +}); +``` +- Bei Event-Create: Package auswählen/kaufen, Eintrag erstellen. +- Checks: z.B. if ($event->package->used_photos >= $event->package->max_photos) abort(403); + +### Tenant-Packages (für Reseller) +```php +Schema::create('tenant_packages', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('package_id')->constrained()->cascadeOnDelete(); + $table->decimal('price', 8, 2); + $table->timestamp('purchased_at'); + $table->timestamp('expires_at'); // z.B. +1 Jahr + $table->integer('used_events')->default(0); // Counter für max_events_per_year + $table->boolean('active')->default(true); + $table->timestamps(); +}); +``` +- Bei Tenant-Registrierung: Free-Reseller oder Upgrade. +- Check: if ($tenant->activePackage->used_events >= $tenant->activePackage->max_events_per_year) block new Event. + +### Ledger und Purchases (ersetzt Credits-Ledger) +```php +Schema::create('package_purchases', function (Blueprint $table) { + $table->id(); + $table->foreignId('tenant_id')->constrained()->nullable(); // Null für Event-spezifisch + $table->foreignId('event_id')->constrained()->nullable(); // Für Endkunden + $table->foreignId('package_id')->constrained(); + $table->string('stripe_id'); // Für Webhook/Idempotenz + $table->decimal('price', 8, 2); + $table->string('type'); // 'endcustomer_event', 'reseller_subscription' + $table->json('metadata'); // z.B. {'event_id': 123} + $table->timestamps(); +}); +``` +- Kein separater Ledger nötig; Purchases tracken alles. + +## Integration und Logik +- **Event-Create Flow**: User wählt Package → Stripe-Checkout (Einmalkauf) → Webhook bestätigt → Event mit package_id erstellen. +- **Reseller-Upgrade**: Im Admin-Dashboard Package auswählen → Subscription erstellen → expires_at setzen. +- **Limits-Checks**: Middleware prüft Event-Package-Limits (Photos/Uploads), Tenant-Package für Event-Anzahl. +- **Fallback**: Bestehende Tenants migrieren zu Free-Paket (z.B. if old_credits > 100 → Standard). +- **UI/Features**: Wasserzeichen: if (!$package->watermark_allowed) hide; Branding: Custom Logo-Upload if allowed. +- **Billing**: Stripe Products für jedes Package; Webhooks updaten Status (z.B. Subscription cancel → active=false). + +## Migration von Altem System +- Entferne: event_credits_balance aus tenants, event_purchases, event_credits_ledger. +- Migriere: Für Events mit Credits → Zuordnen zu Free-Paket; Tenant-Balance → Initial Reseller S. +- Skript: Artisan Command `php artisan migrate:packages` für Daten-Transfer. + +Dieses Design ist final und bereit für Implementierung. Updates via PR. \ No newline at end of file diff --git a/docs/prp/README.md b/docs/prp/README.md index 2c12215..030480a 100644 --- a/docs/prp/README.md +++ b/docs/prp/README.md @@ -12,7 +12,8 @@ This directory supersedes the legacy `fotospiel_prp.md`. Content is split into s - 05-admin-superadmin.md — Super Admin web console (Filament) - 06-tenant-admin-pwa.md — Store-ready Tenant Admin PWA - 07-guest-pwa.md — Guest (event attendee) PWA -- 08-billing.md — Event credits MVP, ledger, purchases +- 08-billing.md — Packages (Einmalkäufe/Subscriptions), ledger, purchases +- 15-packages-design.md — Package definitions, schema, integration - 09-security-compliance.md — RBAC, audit, GDPR - 10-storage-media-pipeline.md — Object storage, processing, CDN - 11-ops-ci-cd.md — CI, releases, environments diff --git a/docs/prp/tenant-app-specs/api-usage.md b/docs/prp/tenant-app-specs/api-usage.md index 6ce73d9..f09b929 100644 --- a/docs/prp/tenant-app-specs/api-usage.md +++ b/docs/prp/tenant-app-specs/api-usage.md @@ -28,8 +28,8 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit ### Stats laden - **GET /api/v1/tenant/dashboard** - **Headers**: `Authorization: Bearer {token}` - - **Response**: `{ credits, active_events, new_photos, task_progress }` - - **Zweck**: Übersicht-Daten für Dashboard-Cards + - **Response**: `{ active_package, active_events, new_photos, task_progress }` + - **Zweck**: Übersicht-Daten für Dashboard-Cards (active_package: current tenant package info) ## Events @@ -47,9 +47,9 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit ### Event erstellen - **POST /api/v1/tenant/events** - **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json` - - **Body**: `{ title, date, location, description }` + - **Body**: `{ title, date, location, description, package_id }` - **Response**: 201 Created mit erstelltem Event - - **Validierung**: Prüft Credit-Balance (1 Credit pro Event) + - **Validierung**: Prüft Tenant-Package (Reseller-Limit) und erstellt Event-Package (Einmalkauf oder Free) ### Event-Details - **GET /api/v1/tenant/events/{slug}** @@ -198,8 +198,8 @@ Alle API-Requests enthalten: ```json { "error": { - "code": "INSUFFICIENT_CREDITS", - "message": "Nicht genügend Credits verfügbar" + "code": "PACKAGE_LIMIT_EXCEEDED", + "message": "Package-Limit überschritten (z.B. max_photos)" } } ``` diff --git a/docs/prp/tenant-app-specs/functional-specs.md b/docs/prp/tenant-app-specs/functional-specs.md index 5767aca..fcbf56a 100644 --- a/docs/prp/tenant-app-specs/functional-specs.md +++ b/docs/prp/tenant-app-specs/functional-specs.md @@ -11,7 +11,7 @@ Die Tenant Admin App muss folgende Kernfunktionen bereitstellen: - **Member-Management**: Hinzufügen/Entfernen von Event-Mitgliedern; Rollen (Admin, Member). - **Task & Emotion Management**: Zuweisen von Tasks und Emotions zu Events; Overrides für Tenant-spezifische Bibliotheken. - **Settings-Management**: Tenant-spezifische Einstellungen (Theme, Limits, Legal Pages). -- **Billing & Purchases**: Kaufen von Event-Credits; Ledger-Übersicht; Integration mit Stripe. +- **Billing & Purchases**: Kaufen von Packages (pro Event oder Tenant); Ledger-Übersicht; Integration mit Stripe. - **Notifications**: Push-Benachrichtigungen für neue Photos, Event-Updates, niedrigen Credit-Balance. - **Offline-Support**: Caching von Events und Photos; Queuing von Uploads/Mutations mit Sync bei Online-Wiederkehr. - **Audit & Compliance**: Logging kritischer Aktionen; ETag-basierte Conflict-Resolution; GDPR-konforme Datenlöschung. @@ -27,7 +27,7 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp ### Core Features - **Event Lifecycle**: - - Erstellen: Erfordert mind. 1 Event-Credit; Slug-Generierung (unique pro Tenant). + - Erstellen: Erfordert Package-Auswahl (Free oder Kauf); Slug-Generierung (unique pro Tenant). - Bearbeiten: Update von Datum, Ort, Tasks, Emotions, Join-Link. - Veröffentlichen: Generiert QR-Code und Share-Link; aktiviert Guest-PWA-Zugriff. - Archivieren: Soft-Delete mit Retention-Periode (GDPR); Credit-Rückerstattung optional. @@ -39,8 +39,8 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp - Bibliothek: Globale + Tenant-spezifische Tasks/Emotions. - Zuweisung: Drag-and-Drop zu Events; Fortschritts-Tracking. - **Billing Integration**: - - Credit-Balance: Anzeige und Kauf von Packs (z.B. 5 Events für 29€). - - Ledger: Historie von Käufen, Consumptions, Refunds. + - Package-Auswahl: Anzeige verfügbarer Packages und Kauf (Einmalkauf/Subscription). + - Ledger: Historie von Package-Käufen und Nutzung. - Stripe-Checkout: Server-side Intent-Erstellung; Webhook-Handling für Confirmation. ### Offline & Sync @@ -62,8 +62,8 @@ Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte ### Events - `GET /tenant/events`: Liste (paginiert, filterbar nach Status/Datum). -- `POST /tenant/events`: Erstellen (validiert Credit-Balance). -- `GET /tenant/events/{id}`: Details inkl. Tasks, Stats. +- `POST /tenant/events`: Erstellen (validiert Tenant-Package und erstellt Event-Package). +- `GET /tenant/events/{id}`: Details inkl. Tasks, Stats, package_limits. - `PATCH /tenant/events/{id}`: Update (ETag für Concurrency). - `DELETE /tenant/events/{id}`: Archivieren. @@ -83,13 +83,13 @@ Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte - `PATCH /tenant/settings`: Update. ### Billing -- `GET /tenant/ledger`: Credit-Historie. -- `POST /tenant/purchases/intent`: Stripe-Checkout-Session erstellen. -- `GET /tenant/credits/balance`: Aktueller Stand. +- `GET /tenant/packages`: Tenant-Packages und Limits. +- `POST /tenant/purchases/intent`: Stripe-Checkout-Session für Package erstellen. +- `GET /api/v1/packages`: Verfügbare Packages. ### Pagination & Errors - Standard: `page`, `per_page` (max 50 für Mobile). -- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Nicht genug Credits"). +- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Package-Limit überschritten"). ## Non-Functional Requirements - **Performance**: Ladezeiten < 2s; Lazy-Loading für Galleries. diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 196f442..0aa0e89 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -178,3 +178,19 @@ export async function createInviteLink(slug: string): Promise<{ link: string; to const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' }); return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite'); } + +export type Package = { + id: number; + name: string; + price: number; + max_photos: number | null; + max_guests: number | null; + gallery_days: number | null; + features: Record; +}; + +export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise { + const response = await authorizedFetch(`/api/v1/packages?type=${type}`); + const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages'); + return data.data ?? []; +} diff --git a/resources/js/admin/pages/EventFormPage.tsx b/resources/js/admin/pages/EventFormPage.tsx index 1b20f69..01339f9 100644 --- a/resources/js/admin/pages/EventFormPage.tsx +++ b/resources/js/admin/pages/EventFormPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react'; +import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -8,15 +9,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'; import { AdminLayout } from '../components/AdminLayout'; -import { createEvent, getEvent, updateEvent } from '../api'; +import { createEvent, getEvent, updateEvent, getPackages } from '../api'; import { isAuthError } from '../auth/tokens'; interface EventFormState { name: string; slug: string; date: string; + package_id: number; isPublished: boolean; } @@ -30,6 +34,7 @@ export default function EventFormPage() { name: '', slug: '', date: '', + package_id: 1, // Default Free package isPublished: false, }); const [autoSlug, setAutoSlug] = React.useState(true); @@ -38,6 +43,11 @@ export default function EventFormPage() { const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(null); + const { data: packages, isLoading: packagesLoading } = useQuery({ + queryKey: ['packages', 'endcustomer'], + queryFn: () => getPackages('endcustomer'), + }); + React.useEffect(() => { let cancelled = false; if (!isEdit || !slugParam) { @@ -109,6 +119,7 @@ export default function EventFormPage() { const payload = { name: trimmedName, slug: trimmedSlug, + package_id: form.package_id, date: form.date || undefined, status: form.isPublished ? 'published' : 'draft', }; @@ -199,6 +210,50 @@ export default function EventFormPage() { onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))} /> +
+ + + + + + + + + Package auswählen + Wählen Sie das Package für Ihr Event. Höhere Packages bieten mehr Limits und Features. + +
+ {packages?.map((pkg) => ( +
+

{pkg.name}

+

{pkg.price} €

+
    +
  • Max Fotos: {pkg.max_photos}
  • +
  • Max Gäste: {pkg.max_guests}
  • +
  • Galerie: {pkg.gallery_days} Tage
  • +
  • Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}
  • +
+
+ ))} +
+
+
+
diff --git a/resources/js/admin/pages/EventsPage.tsx b/resources/js/admin/pages/EventsPage.tsx index d3f6e01..47d3d48 100644 --- a/resources/js/admin/pages/EventsPage.tsx +++ b/resources/js/admin/pages/EventsPage.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react'; +import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Package as PackageIcon } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -8,7 +9,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { AdminLayout } from '../components/AdminLayout'; -import { getEvents, TenantEvent } from '../api'; +import { getEvents, TenantEvent, getPackages } from '../api'; import { isAuthError } from '../auth/tokens'; export default function EventsPage() { @@ -17,6 +18,11 @@ export default function EventsPage() { const [error, setError] = React.useState(null); const navigate = useNavigate(); + const { data: tenantPackages } = useQuery({ + queryKey: ['tenant-packages'], + queryFn: () => getPackages('reseller'), // or separate endpoint + }); + React.useEffect(() => { (async () => { try { @@ -53,6 +59,33 @@ export default function EventsPage() { subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen." actions={actions} > + {tenantPackages && tenantPackages.length > 0 && ( + + + + + Aktuelles Package + + + Ihr aktuelles Reseller-Package und verbleibende Limits. + + + +
+

Aktives Package

+

{tenantPackages.find(p => p.active)?.package?.name || 'Kein aktives Package'}

+
+
+

Verbleibende Events

+

{tenantPackages.find(p => p.active)?.remaining_events || 0}

+
+
+

Ablauf

+

{tenantPackages.find(p => p.active)?.expires_at || 'Kein Package'}

+
+
+
+ )} {error && ( Fehler beim Laden diff --git a/resources/js/guest/pages/GalleryPage.tsx b/resources/js/guest/pages/GalleryPage.tsx index cf3bda8..380d8a0 100644 --- a/resources/js/guest/pages/GalleryPage.tsx +++ b/resources/js/guest/pages/GalleryPage.tsx @@ -2,13 +2,14 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { Page } from './_util'; import { useParams, useSearchParams } from 'react-router-dom'; import { usePollGalleryDelta } from '../polling/usePollGalleryDelta'; -import { Card, CardContent } from '@/components/ui/card'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import FiltersBar, { type GalleryFilter } from '../components/FiltersBar'; -import { Heart } from 'lucide-react'; +import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react'; import { likePhoto } from '../services/photosApi'; import PhotoLightbox from './PhotoLightbox'; +import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi'; export default function GalleryPage() { const { slug } = useParams(); @@ -17,6 +18,11 @@ export default function GalleryPage() { const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState(null); const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false); + const [event, setEvent] = useState(null); + const [eventPackage, setEventPackage] = useState(null); + const [stats, setStats] = useState(null); + const [eventLoading, setEventLoading] = useState(true); + const [searchParams] = useSearchParams(); const photoIdParam = searchParams.get('photoId'); // Auto-open lightbox if photoId in query params @@ -30,6 +36,31 @@ export default function GalleryPage() { } }, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]); + // Load event and package info + useEffect(() => { + if (!slug) return; + + const loadEventData = async () => { + try { + setEventLoading(true); + const [eventData, packageData, statsData] = await Promise.all([ + fetchEvent(slug), + getEventPackage(slug), + fetchStats(slug), + ]); + setEvent(eventData); + setEventPackage(packageData); + setStats(statsData); + } catch (err) { + console.error('Failed to load event data', err); + } finally { + setEventLoading(false); + } + }; + + loadEventData(); + }, [slug]); + const myPhotoIds = React.useMemo(() => { try { const raw = localStorage.getItem('my-photo-ids'); @@ -68,19 +99,68 @@ export default function GalleryPage() { } } + if (eventLoading) { + return

Lade Event-Info...

; + } + return ( + + + + + Galerie: {event?.name || 'Event'} + + + +
+ +

Online Gäste

+

{stats?.onlineGuests || 0}

+
+
+ +

Gesamt Likes

+

{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}

+
+
+ +

Gesamt Fotos

+

{photos.length}

+
+ {eventPackage && ( +
+ +

Package

+

{eventPackage.package.name}

+
+
+
+

+ {eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos +

+ {new Date(eventPackage.expires_at) < new Date() && ( +

Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}

+ )} +
+ )} +
+
+ {newCount > 0 && ( - + {newCount} neue Fotos verfügbar.{' '} )} - {loading &&

Lade…

} -
+ {loading &&

Lade…

} +
{list.map((p: any) => { // Debug: Log image URLs const imgSrc = p.thumbnail_path || p.file_path; diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx index 2f25b59..0511006 100644 --- a/resources/js/guest/pages/UploadPage.tsx +++ b/resources/js/guest/pages/UploadPage.tsx @@ -21,6 +21,7 @@ import { Zap, ZapOff, } from 'lucide-react'; +import { getEventPackage, type EventPackage } from '../services/eventApi'; interface Task { id: number; @@ -85,6 +86,9 @@ export default function UploadPage() { const [uploadProgress, setUploadProgress] = useState(0); const [uploadError, setUploadError] = useState(null); + const [eventPackage, setEventPackage] = useState(null); + const [canUpload, setCanUpload] = useState(true); + const [showPrimer, setShowPrimer] = useState(() => { if (typeof window === 'undefined') return false; return window.localStorage.getItem(primerStorageKey) !== '1'; @@ -201,6 +205,30 @@ export default function UploadPage() { }; }, [slug, taskId, emotionSlug]); + // Check upload limits + useEffect(() => { + if (!slug || !task) return; + + const checkLimits = async () => { + try { + const pkg = await getEventPackage(slug); + setEventPackage(pkg); + if (pkg && pkg.used_photos >= pkg.package.max_photos) { + setCanUpload(false); + setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.'); + } else { + setCanUpload(true); + } + } catch (err) { + console.error('Failed to check package limits', err); + setCanUpload(false); + setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.'); + } + }; + + checkLimits(); + }, [slug, task]); + const stopStream = useCallback(() => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); @@ -428,7 +456,7 @@ export default function UploadPage() { ); const handleUsePhoto = useCallback(async () => { - if (!slug || !reviewPhoto || !task) return; + if (!slug || !reviewPhoto || !task || !canUpload) return; setMode('uploading'); setUploadProgress(5); setUploadError(null); @@ -459,9 +487,10 @@ export default function UploadPage() { } setStatusMessage(''); } - }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task]); + }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]); const handleGalleryPick = useCallback((event: React.ChangeEvent) => { + if (!canUpload) return; const file = event.target.files?.[0]; if (!file) return; setUploadError(null); @@ -474,7 +503,7 @@ export default function UploadPage() { setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.'); }; reader.readAsDataURL(file); - }, []); + }, [canUpload]); const difficultyBadgeClass = useMemo(() => { if (!task) return 'text-white'; @@ -491,6 +520,8 @@ export default function UploadPage() { const isCameraActive = permissionState === 'granted' && mode !== 'uploading'; const showTaskOverlay = task && mode !== 'uploading'; + const isUploadDisabled = !canUpload || !task; + useEffect(() => () => { resetCountdownTimer(); if (uploadProgressTimerRef.current) { @@ -527,6 +558,24 @@ export default function UploadPage() { ); } + if (!canUpload) { + return ( +
+
+
+ + + + Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos). + Kontaktieren Sie den Organisator für ein Package-Upgrade. + + +
+ +
+ ); + } + const renderPrimer = () => ( showPrimer && (
diff --git a/resources/js/guest/services/eventApi.ts b/resources/js/guest/services/eventApi.ts index cbeb3cd..1e07344 100644 --- a/resources/js/guest/services/eventApi.ts +++ b/resources/js/guest/services/eventApi.ts @@ -14,6 +14,19 @@ export interface EventData { }; } +export interface PackageData { + id: number; + name: string; + max_photos: number; +} + +export interface EventPackage { + id: number; + used_photos: number; + expires_at: string; + package: PackageData; +} + export interface EventStats { onlineGuests: number; tasksSolved: number; @@ -39,4 +52,13 @@ export async function fetchStats(slug: string): Promise { tasksSolved: json.tasksSolved ?? 0, latestPhotoAt: json.latestPhotoAt ?? null, }; +} + +export async function getEventPackage(slug: string): Promise { + const res = await fetch(`/api/v1/events/${slug}/package`); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error('Failed to load event package'); + } + return await res.json(); } \ No newline at end of file diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php new file mode 100644 index 0000000..020dc1c --- /dev/null +++ b/resources/lang/de/marketing.php @@ -0,0 +1,40 @@ + [ + 'title' => 'Unsere Packages – Wählen Sie Ihr Event-Paket', + 'hero_title' => 'Entdecken Sie unsere flexiblen Packages', + 'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.', + 'cta_explore' => 'Packages entdecken', + 'tab_endcustomer' => 'Endkunden', + 'tab_reseller' => 'Reseller & Agenturen', + 'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)', + 'section_reseller' => 'Packages für Reseller (Jährliches Abo)', + 'free' => 'Kostenlos', + 'one_time' => 'Einmalkauf', + 'subscription' => 'Abo', + 'year' => 'Jahr', + 'max_photos' => 'Fotos', + 'max_guests' => 'Gäste', + 'gallery_days' => 'Tage Galerie', + 'max_events_year' => 'Events/Jahr', + 'buy_now' => 'Jetzt kaufen', + 'subscribe_now' => 'Jetzt abonnieren', + 'faq_title' => 'Häufige Fragen zu Packages', + 'faq_q1' => 'Was ist ein Package?', + 'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.', + 'faq_q2' => 'Kann ich upgraden?', + 'faq_a2' => 'Ja, wählen Sie bei Event-Erstellung ein höheres Package oder upgraden Sie später.', + 'faq_q3' => 'Was passiert bei Ablauf?', + 'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.', + 'faq_q4' => 'Zahlungssicher?', + 'faq_a4' => 'Ja, via Stripe oder PayPal – sicher und GDPR-konform.', + 'final_cta' => 'Bereit für Ihr nächstes Event?', + 'contact_us' => 'Kontaktieren Sie uns', + 'feature_live_slideshow' => 'Live-Slideshow', + 'feature_analytics' => 'Analytics', + 'feature_watermark' => 'Wasserzeichen', + 'feature_branding' => 'Branding', + 'feature_support' => 'Support', + ], +]; \ No newline at end of file diff --git a/resources/views/legal/datenschutz.blade.php b/resources/views/legal/datenschutz.blade.php index 2636cb6..8ba4582 100644 --- a/resources/views/legal/datenschutz.blade.php +++ b/resources/views/legal/datenschutz.blade.php @@ -10,6 +10,9 @@

Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.

Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt

Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.

+

Zahlungen und Packages

+

Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen. Siehe Stripe Datenschutz und PayPal Datenschutz.

+

Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.

Ihre Rechte: Auskunft, Löschung, Widerspruch. Kontaktieren Sie uns unter Kontakt.

Cookies: Nur funktionale Cookies für die PWA.

diff --git a/resources/views/legal/impressum.blade.php b/resources/views/legal/impressum.blade.php index 0658f7a..6b3098a 100644 --- a/resources/views/legal/impressum.blade.php +++ b/resources/views/legal/impressum.blade.php @@ -15,6 +15,8 @@ Vertreten durch: Max Mustermann
Kontakt: Kontakt

Umsatzsteuer-ID: DE123456789

+

Monetarisierung

+

Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de

Registergericht: Amtsgericht Musterstadt

Handelsregister: HRB 12345

diff --git a/resources/views/marketing.blade.php b/resources/views/marketing.blade.php index e0f5738..e9a7128 100644 --- a/resources/views/marketing.blade.php +++ b/resources/views/marketing.blade.php @@ -43,9 +43,9 @@
Blog - Pricing + Packages Contact - Jetzt starten + Packages entdecken @@ -58,7 +58,7 @@

Fotospiel

Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.

- Jetzt starten – Kostenlos + Jetzt starten – Kostenlos
Event-Fotos mit QR @@ -125,41 +125,15 @@
- +
-

Tarife für QR-Events

-
-
-

Basic

-

0 €

-
    -
  • 1 Event mit QR
  • -
  • 100 Fotos
  • -
  • Grundfunktionen
  • -
- Kostenlos starten -
-
-

Standard

-

99 €

-
    -
  • 10 Events mit QR
  • -
  • Unbegrenzt Fotos
  • -
  • Erweiterte Features
  • -
- Kaufen -
-
-

Premium

-

199 €

-
    -
  • 50 Events mit QR
  • -
  • Support & Custom
  • -
  • Alle Features
  • -
- Kaufen -
+

Unsere Packages

+

Wählen Sie das passende Paket für Ihr Event – von kostenlos bis premium.

+
diff --git a/resources/views/marketing/occasions.blade.php b/resources/views/marketing/occasions.blade.php index 4b14e29..970fa88 100644 --- a/resources/views/marketing/occasions.blade.php +++ b/resources/views/marketing/occasions.blade.php @@ -32,7 +32,7 @@ Pricing Contact - Jetzt starten + Packages wählen
@@ -41,7 +41,7 @@

Fotospiel für {{ ucfirst($type) }}

Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für {{ ucfirst($type) }} – einfach, mobil und datenschutzkonform.

- Event starten + Package wählen
diff --git a/resources/views/marketing/packages.blade.php b/resources/views/marketing/packages.blade.php new file mode 100644 index 0000000..4dac58a --- /dev/null +++ b/resources/views/marketing/packages.blade.php @@ -0,0 +1,194 @@ +@extends('layouts.marketing') + +@section('title', __('marketing.packages.title')) + +@section('content') +
+
+ +
+

+ {{ __('marketing.packages.hero_title') }} +

+

+ {{ __('marketing.packages.hero_description') }} +

+ + {{ __('marketing.packages.cta_explore') }} + +
+ + + + + +
+

+ {{ __('marketing.packages.section_endcustomer') }} +

+
+ @foreach(\App\Models\Package::where('type', 'endcustomer')->orderBy('price')->get() as $package) +
+
+

{{ $package->name }}

+
+ {{ $package->price }} € +
+

{{ $package->price == 0 ? __('marketing.packages.free') : __('marketing.packages.one_time') }}

+
+
    + @if($package->max_photos) +
  • + + + + {{ $package->max_photos }} {{ __('marketing.packages.max_photos') }} +
  • + @endif + @if($package->max_guests) +
  • + + + + {{ $package->max_guests }} {{ __('marketing.packages.max_guests') }} +
  • + @endif + @if($package->gallery_days) +
  • + + + + {{ $package->gallery_days }} {{ __('marketing.packages.gallery_days') }} +
  • + @endif + @if($package->features) + @foreach(json_decode($package->features, true) as $feature => $enabled) + @if($enabled) +
  • + + + + {{ __('marketing.packages.feature_' . $feature) }} +
  • + @endif + @endforeach + @endif +
+ + {{ __('marketing.packages.buy_now') }} + +
+ @endforeach +
+
+ + +
+

+ {{ __('marketing.packages.section_reseller') }} +

+
+ @foreach(\App\Models\Package::where('type', 'reseller')->orderBy('price')->get() as $package) +
+
+

{{ $package->name }}

+
+ {{ $package->price }} € / {{ __('marketing.packages.year') }} +
+

{{ __('marketing.packages.subscription') }}

+
+
    + @if($package->max_events_per_year) +
  • + + + + {{ $package->max_events_per_year }} {{ __('marketing.packages.max_events_year') }} +
  • + @endif + @if($package->features) + @foreach(json_decode($package->features, true) as $feature => $enabled) + @if($enabled) +
  • + + + + {{ __('marketing.packages.feature_' . $feature) }} +
  • + @endif + @endforeach + @endif +
+ + {{ __('marketing.packages.subscribe_now') }} + +
+ @endforeach +
+
+ + +
+

+ {{ __('marketing.packages.faq_title') }} +

+
+
+

{{ __('marketing.packages.faq_q1') }}

+

{{ __('marketing.packages.faq_a1') }}

+
+
+

{{ __('marketing.packages.faq_q2') }}

+

{{ __('marketing.packages.faq_a2') }}

+
+
+

{{ __('marketing.packages.faq_q3') }}

+

{{ __('marketing.packages.faq_a3') }}

+
+
+

{{ __('marketing.packages.faq_q4') }}

+

{{ __('marketing.packages.faq_a4') }}

+
+
+
+ + +
+

+ {{ __('marketing.packages.final_cta') }} +

+ + {{ __('marketing.packages.contact_us') }} + +
+
+
+ +@push('scripts') + +@endpush \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 2af76dd..fb4b2b0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,7 +7,8 @@ use App\Http\Controllers\Api\Tenant\TaskController; use App\Http\Controllers\Api\Tenant\PhotoController; use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; -use App\Http\Controllers\Tenant\CreditController; +use App\Http\Controllers\Api\PackageController; +use App\Http\Controllers\Api\TenantPackageController; use Illuminate\Support\Facades\Route; Route::prefix('v1')->name('api.v1.')->group(function () { @@ -86,12 +87,22 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('tenant.settings.validate-domain'); }); - Route::prefix('credits')->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')->group(function () { + Route::get('/', [PackageController::class, 'index'])->name('packages.index'); + Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); + }); + + Route::prefix('stripe')->group(function () { + Route::post('/payment-intent', [StripeController::class, 'createPaymentIntent'])->name('stripe.payment-intent'); + Route::post('/subscription', [StripeController::class, 'createSubscription'])->name('stripe.subscription'); + }); + + Route::prefix('tenant/packages')->group(function () { + Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index'); }); }); + + // Stripe Webhook (no auth) + Route::post('/stripe/webhook', [StripeWebhookController::class, 'handleWebhook']) + ->name('stripe.webhook'); }); diff --git a/routes/web.php b/routes/web.php index 6ca26b4..79b997e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,6 +6,7 @@ use Inertia\Inertia; // Marketing-Seite mit Locale-Prefix Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () { Route::view('/', 'marketing')->name('marketing'); + Route::view('/packages', 'marketing.packages')->name('packages'); Route::get('/occasions/{type}', function ($type) { return view('marketing.occasions', ['type' => $type]); })->name('occasions.type'); diff --git a/tests/Feature/EventControllerTest.php b/tests/Feature/EventControllerTest.php new file mode 100644 index 0000000..42495f0 --- /dev/null +++ b/tests/Feature/EventControllerTest.php @@ -0,0 +1,127 @@ +create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]); + + $response = $this->actingAs($user) + ->postJson('/api/v1/tenant/events', [ + 'name' => 'Test Event', + 'slug' => 'test-event', + 'date' => '2025-10-01', + 'package_id' => $package->id, + ]); + + $response->assertStatus(201); + + $this->assertDatabaseHas('events', [ + 'tenant_id' => $tenant->id, + 'name' => 'Test Event', + 'slug' => 'test-event', + ]); + + $event = Event::latest()->first(); + $this->assertDatabaseHas('event_packages', [ + 'event_id' => $event->id, + 'package_id' => $package->id, + ]); + + $this->assertDatabaseHas('package_purchases', [ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'type' => 'endcustomer_event', + 'provider_id' => 'manual', + ]); + } + + public function test_create_event_without_package_fails(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + + $response = $this->actingAs($user) + ->postJson('/api/v1/tenant/events', [ + 'name' => 'Test Event', + 'slug' => 'test-event', + 'date' => '2025-10-01', + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['package_id']); + } + + public function test_create_event_with_reseller_package_limits_events(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]); + TenantPackage::factory()->create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'used_events' => 0, + 'active' => true, + 'expires_at' => now()->addYear(), + ]); + + // First event succeeds + $response1 = $this->actingAs($user) + ->postJson('/api/v1/tenant/events', [ + 'name' => 'First Event', + 'slug' => 'first-event', + 'date' => '2025-10-01', + 'package_id' => $package->id, // Use reseller package for event? Adjust if needed + ]); + + $response1->assertStatus(201); + + // Second event fails due to limit + $response2 = $this->actingAs($user) + ->postJson('/api/v1/tenant/events', [ + 'name' => 'Second Event', + 'slug' => 'second-event', + 'date' => '2025-10-02', + 'package_id' => $package->id, + ]); + + $response2->assertStatus(402) + ->assertJson(['error' => 'No available package for event creation']); + } + + public function test_upload_exceeds_package_limit_fails(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->create(['tenant_id' => $tenant->id]); + $event = Event::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0 + EventPackage::factory()->create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'used_photos' => 0, + ]); + + $response = $this->actingAs($user) + ->postJson("/api/v1/events/{$event->slug}/photos", [ + 'photo' => 'test-photo.jpg', + ]); + + $response->assertStatus(402) + ->assertJson(['error' => 'Upload limit reached for this event']); + } +} \ No newline at end of file diff --git a/tests/Feature/StripeWebhookTest.php b/tests/Feature/StripeWebhookTest.php new file mode 100644 index 0000000..98c5e28 --- /dev/null +++ b/tests/Feature/StripeWebhookTest.php @@ -0,0 +1,120 @@ + 'whsec_test_secret']); + } + + public function test_handle_payment_intent_succeeded_creates_event_package(): void + { + $tenant = \App\Models\Tenant::factory()->create(); + $event = \App\Models\Event::factory()->create(['tenant_id' => $tenant->id]); + $package = Package::factory()->create(['type' => 'endcustomer']); + + $payload = [ + 'id' => 'evt_test', + 'type' => 'payment_intent.succeeded', + 'data' => [ + 'object' => [ + 'id' => 'pi_test', + 'metadata' => [ + 'type' => 'endcustomer_event', + 'tenant_id' => (string) $tenant->id, + 'event_id' => (string) $event->id, + 'package_id' => (string) $package->id, + ], + ], + ], + ]; + + $sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true)); + + $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ + 'Stripe-Signature' => $sigHeader, + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('package_purchases', [ + 'package_id' => $package->id, + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'type' => 'endcustomer_event', + 'provider_id' => 'pi_test', + ]); + + $this->assertDatabaseHas('event_packages', [ + 'event_id' => $event->id, + 'package_id' => $package->id, + ]); + } + + public function test_handle_invoice_paid_renews_tenant_package(): void + { + $tenant = \App\Models\Tenant::factory()->create(); + $package = Package::factory()->create(['type' => 'reseller']); + + $payload = [ + 'id' => 'evt_test', + 'type' => 'invoice.paid', + 'data' => [ + 'object' => [ + 'subscription' => 'sub_test', + 'metadata' => [ + 'type' => 'reseller_subscription', + 'tenant_id' => (string) $tenant->id, + 'package_id' => (string) $package->id, + ], + ], + ], + ]; + + $sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true)); + + $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ + 'Stripe-Signature' => $sigHeader, + ]); + + $response->assertStatus(200); + + $this->assertDatabaseHas('package_purchases', [ + 'package_id' => $package->id, + 'tenant_id' => $tenant->id, + 'type' => 'reseller_subscription', + ]); + + $tenantPackage = TenantPackage::where('tenant_id', $tenant->id)->first(); + $this->assertNotNull($tenantPackage); + $this->assertTrue($tenantPackage->expires_at->isFuture()); + } + + public function test_webhook_rejects_invalid_signature(): void + { + $payload = ['type' => 'invalid']; + $sigHeader = 'invalid'; + + $response = $this->postJson('/api/v1/stripe/webhook', $payload, [ + 'Stripe-Signature' => $sigHeader, + ]); + + $response->assertStatus(400); + } +} \ No newline at end of file diff --git a/tests/e2e/package-flow.test.ts b/tests/e2e/package-flow.test.ts new file mode 100644 index 0000000..7ebbd9e --- /dev/null +++ b/tests/e2e/package-flow.test.ts @@ -0,0 +1,60 @@ +import { test, expect } from '@playwright/test'; +import { chromium } from 'playwright'; + +test.describe('Package Flow in Admin PWA', () => { + test('Create event with package and verify limits', async ({ page }) => { + // Assume logged in as tenant admin, navigate to events page + await page.goto('/admin/events'); + + // Click create event button + await page.click('[data-testid="create-event"]'); + await expect(page).toHaveURL(/\/admin\/events\/create/); + + // Fill form + await page.fill('[name="name"]', 'Test Package Event'); + await page.fill('[name="slug"]', 'test-package-event'); + await page.fill('[name="date"]', '2025-10-01'); + + // Select package from dropdown + await page.selectOption('[name="package_id"]', '1'); // Assume ID 1 is Starter package + await expect(page.locator('[name="package_id"]')).toHaveValue('1'); + + // Submit + await page.click('[type="submit"]'); + await expect(page).toHaveURL(/\/admin\/events/); + + // Verify event created and package assigned + await expect(page.locator('text=Test Package Event')).toBeVisible(); + await expect(page.locator('text=Starter')).toBeVisible(); // Package name in table + + // Check dashboard limits + await page.goto('/admin/dashboard'); + await expect(page.locator('text=Remaining Photos')).toContainText('300'); // Starter limit + + // Try to create another event to test reseller limit if applicable + // (Skip for endcustomer; assume tenant has reseller package with limit 1) + await page.goto('/admin/events'); + await page.click('[data-testid="create-event"]'); + await page.fill('[name="name"]', 'Second Event'); + await page.fill('[name="slug"]', 'second-event'); + await page.fill('[name="date"]', '2025-10-02'); + await page.selectOption('[name="package_id"]', '1'); + await page.click('[type="submit"]'); + + // If limit reached, expect error + await expect(page.locator('text=No available package')).toBeVisible(); + }); + + test('Upload blocked when package limit reached in Guest PWA', async ({ page }) => { + // Assume event with package limit 0 created + await page.goto('/e/test-limited-event'); // Slug of event with max_photos = 0 + + // Navigate to upload + await page.click('text=Upload'); + await expect(page).toHaveURL(/\/upload/); + + // Expect upload disabled and error message + await expect(page.locator('button:disabled')).toBeVisible(); // Upload button disabled + await expect(page.locator('text=Upload-Limit erreicht')).toBeVisible(); + }); +}); \ No newline at end of file