diff --git a/app/Console/Commands/MigrateLegacyPurchases.php b/app/Console/Commands/MigrateLegacyPurchases.php new file mode 100644 index 0000000..243e130 --- /dev/null +++ b/app/Console/Commands/MigrateLegacyPurchases.php @@ -0,0 +1,78 @@ +get(); + + if ($legacyPurchases->isEmpty()) { + $this->info('No legacy purchases found.'); + return 0; + } + + $this->info("Found {$legacyPurchases->count()} legacy purchases."); + + foreach ($legacyPurchases as $purchase) { + if (!$purchase->user_id) { + // Create temp user if no user + $tempUser = User::create([ + 'name' => 'Legacy User ' . $purchase->id, + 'email' => 'legacy' . $purchase->id . '@fotospiel.local', + 'password' => Hash::make('legacy'), + 'username' => 'legacy' . $purchase->id, + 'first_name' => 'Legacy', + 'last_name' => 'User', + 'address' => 'Legacy Address', + 'phone' => '000000000', + 'email_verified_at' => now(), + ]); + + $tempTenant = Tenant::create([ + 'user_id' => $tempUser->id, + 'name' => 'Legacy Tenant ' . $purchase->id, + 'status' => 'active', + ]); + + $purchase->update([ + 'user_id' => $tempUser->id, + 'tenant_id' => $tempTenant->id, + ]); + + // Assign default free package + TenantPackage::create([ + 'tenant_id' => $tempTenant->id, + 'package_id' => 1, // Assume free package ID 1 + 'expires_at' => now()->addYear(), + 'is_active' => true, + ]); + + $this->info("Created temp user/tenant for purchase {$purchase->id}"); + } else { + $user = User::find($purchase->user_id); + if ($user && $user->tenant) { + $purchase->update(['tenant_id' => $user->tenant->id]); + $this->info("Assigned tenant for purchase {$purchase->id}"); + } else { + $this->error("Could not assign tenant for purchase {$purchase->id}"); + } + } + } + + $this->info('Legacy migration completed.'); + return 0; + } +} \ No newline at end of file diff --git a/app/Enums/PackageType.php b/app/Enums/PackageType.php new file mode 100644 index 0000000..e3dbdc8 --- /dev/null +++ b/app/Enums/PackageType.php @@ -0,0 +1,9 @@ +label('Username or Email') + ->required() + ->autofocus(), + TextInput::make('data.password') + ->password() + ->required() + ->extraAttributes(['tabindex' => 2]), + Checkbox::make('data.remember') + ->label('Remember me'), + ]; + } + + public function submit(): void + { + $data = $this->form->getState(); + + $credentials = $this->getCredentialsFromFormData($data); + + if (! Auth::attempt($credentials, $data['remember'] ?? false)) { + throw ValidationException::withMessages([ + 'data.username_or_email' => __('auth.failed'), + ]); + } + + $user = Auth::user(); + + if (! $user->email_verified_at) { + Auth::logout(); + + throw ValidationException::withMessages([ + 'data.username_or_email' => 'Your email address is not verified. Please check your email for a verification link.', + ]); + } + + if (! $user->tenant) { + Auth::logout(); + + throw ValidationException::withMessages([ + 'data.username_or_email' => 'No tenant associated with your account. Contact support.', + ]); + } + + session()->regenerate(); + + $this->redirect($this->getRedirectUrl()); + } + + protected function getCredentialsFromFormData(array $data): array + { + $usernameOrEmail = $data['username_or_email']; + $password = $data['password']; + + $credentials = ['password' => $password]; + + if (filter_var($usernameOrEmail, FILTER_VALIDATE_EMAIL)) { + $credentials['email'] = $usernameOrEmail; + } else { + $credentials['username'] = $usernameOrEmail; + } + + return $credentials; + } + +} \ No newline at end of file diff --git a/app/Filament/Pages/SuperAdminProfile.php b/app/Filament/Pages/SuperAdminProfile.php deleted file mode 100644 index ad08ed3..0000000 --- a/app/Filament/Pages/SuperAdminProfile.php +++ /dev/null @@ -1,59 +0,0 @@ -label(__('Username')) - ->maxLength(32) - ->unique(ignoreRecord: true); - } - - protected function getPreferredLocaleFormComponent(): Component - { - $supported = collect(explode(',', (string) env('APP_SUPPORTED_LOCALES', 'de,en'))) - ->map(fn ($l) => trim((string) $l)) - ->filter() - ->unique() - ->values() - ->all(); - - if (empty($supported)) { - $supported = array_values(array_unique(array_filter([ - config('app.locale'), - config('app.fallback_locale'), - ]))); - } - - $options = collect($supported)->mapWithKeys(fn ($l) => [$l => strtoupper($l)])->all(); - - return Select::make('preferred_locale') - ->label(__('Language')) - ->required() - ->options($options); - } - - public function form(Schema $schema): Schema - { - return $schema - ->components([ - $this->getNameFormComponent(), - $this->getEmailFormComponent(), - $this->getUsernameFormComponent(), - $this->getPreferredLocaleFormComponent(), - $this->getPasswordFormComponent(), - $this->getPasswordConfirmationFormComponent(), - $this->getCurrentPasswordFormComponent(), - ]); - } -} - diff --git a/app/Filament/Resources/EventPurchaseResource.php b/app/Filament/Resources/EventPurchaseResource.php index 2841c2a..b1c79cd 100644 --- a/app/Filament/Resources/EventPurchaseResource.php +++ b/app/Filament/Resources/EventPurchaseResource.php @@ -32,18 +32,11 @@ class EventPurchaseResource extends Resource { protected static ?string $model = EventPurchase::class; - public static function getNavigationIcon(): string + public static function shouldRegisterNavigation(): bool { - return 'heroicon-o-shopping-cart'; + return false; } - public static function getNavigationGroup(): string - { - return 'Billing'; - } - - protected static ?int $navigationSort = 10; - public static function form(Schema $schema): Schema { return $schema diff --git a/app/Filament/Resources/PackageResource/Pages/CreatePackage.php b/app/Filament/Resources/PackageResource/Pages/CreatePackage.php new file mode 100644 index 0000000..45f0e1b --- /dev/null +++ b/app/Filament/Resources/PackageResource/Pages/CreatePackage.php @@ -0,0 +1,11 @@ +schema([ + Select::make('tenant_id') + ->label('Tenant') + ->relationship('tenant', 'name') + ->searchable() + ->preload() + ->nullable(), + Select::make('event_id') + ->label('Event') + ->relationship('event', 'name') + ->searchable() + ->preload() + ->nullable(), + Select::make('package_id') + ->label('Package') + ->relationship('package', 'name') + ->searchable() + ->preload() + ->required(), + TextInput::make('provider_id') + ->label('Provider ID') + ->required() + ->maxLength(255), + TextInput::make('price') + ->label('Price') + ->numeric() + ->step(0.01) + ->prefix('€') + ->required(), + Select::make('type') + ->label('Type') + ->options([ + 'endcustomer_event' => 'Endcustomer Event', + 'reseller_subscription' => 'Reseller Subscription', + ]) + ->required(), + Textarea::make('metadata') + ->label('Metadata') + ->json() + ->columnSpanFull(), + Toggle::make('refunded') + ->label('Refunded') + ->default(false), + ]) + ->columns(2); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + BadgeColumn::make('type') + ->label('Type') + ->color(fn (string $state): string => match($state) { + 'endcustomer_event' => 'info', + 'reseller_subscription' => 'success', + default => 'gray', + }), + TextColumn::make('tenant.name') + ->label('Tenant') + ->searchable() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('event.name') + ->label('Event') + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('package.name') + ->label('Package') + ->badge() + ->color('success'), + TextColumn::make('price') + ->label('Price') + ->money('EUR') + ->sortable(), + TextColumn::make('purchased_at') + ->dateTime() + ->sortable(), + BadgeColumn::make('refunded') + ->label('Status') + ->color(fn (bool $state): string => $state ? 'danger' : 'success'), + TextColumn::make('provider_id') + ->copyable() + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('type') + ->options([ + 'endcustomer_event' => 'Endcustomer Event', + 'reseller_subscription' => 'Reseller Subscription', + ]), + Filter::make('purchased_at') + ->form([ + DateTimePicker::make('started_from'), + DateTimePicker::make('ended_before'), + ]) + ->query(function (Builder $query, array $data): Builder { + return $query + ->when( + $data['started_from'], + fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '>=', $date), + ) + ->when( + $data['ended_before'], + fn (Builder $query, $date): Builder => $query->whereDate('purchased_at', '<=', $date), + ); + }), + SelectFilter::make('tenant_id') + ->label('Tenant') + ->relationship('tenant', 'name') + ->searchable(), + ]) + ->actions([ + ViewAction::make(), + EditAction::make(), + Action::make('refund') + ->label('Refund') + ->color('danger') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn (PackagePurchase $record): bool => !$record->refunded) + ->action(function (PackagePurchase $record) { + $record->update(['refunded' => true]); + // TODO: Call Stripe/PayPal API for actual refund + Log::info('Refund processed for purchase ID: ' . $record->id); + }), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->emptyStateHeading('No Purchases Found') + ->emptyStateDescription('Create your first purchase.'); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPurchases::route('/'), + 'create' => Pages\CreatePurchase::route('/create'), + 'view' => Pages\ViewPurchase::route('/{record}'), + 'edit' => Pages\EditPurchase::route('/{record}/edit'), + ]; + } + + public static function getRelations(): array + { + return [ + // Add RelationManagers if needed + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/PurchaseResource/Pages/CreatePurchase.php b/app/Filament/Resources/PurchaseResource/Pages/CreatePurchase.php new file mode 100644 index 0000000..2d8b4b4 --- /dev/null +++ b/app/Filament/Resources/PurchaseResource/Pages/CreatePurchase.php @@ -0,0 +1,11 @@ +label('Refund') + ->color('danger') + ->icon('heroicon-o-arrow-uturn-left') + ->requiresConfirmation() + ->visible(fn ($record): bool => !$record->refunded) + ->action(function ($record) { + $record->update(['refunded' => true]); + // TODO: Call Stripe/PayPal API for actual refund + }), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantPackageResource.php b/app/Filament/Resources/TenantPackageResource.php new file mode 100644 index 0000000..9d947fc --- /dev/null +++ b/app/Filament/Resources/TenantPackageResource.php @@ -0,0 +1,119 @@ +schema([ + Section::make('Package Details') + ->schema([ + Select::make('package_id') + ->relationship('package', 'name') + ->required() + ->searchable(), + Select::make('tenant_id') + ->relationship('tenant', 'name') + ->required() + ->default(fn () => Auth::user()->tenant_id) + ->disabled(), + DateTimePicker::make('expires_at') + ->required(), + Toggle::make('is_active') + ->default(true), + ]) + ->columns(1), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('package.name') + ->searchable() + ->sortable(), + TextColumn::make('tenant.name') + ->badge() + ->color('success'), + TextColumn::make('expires_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + IconColumn::make('is_active') + ->boolean(), + TextColumn::make('created_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + // + ]) + ->actions([ + ActionGroup::make([ + ViewAction::make(), + EditAction::make(), + DeleteAction::make(), + ]), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id)); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListTenantPackages::route('/'), + 'create' => Pages\CreateTenantPackage::route('/create'), + 'view' => Pages\ViewTenantPackage::route('/{record}'), + 'edit' => Pages\EditTenantPackage::route('/{record}/edit'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php new file mode 100644 index 0000000..b935768 --- /dev/null +++ b/app/Filament/Resources/TenantPackageResource/Pages/CreateTenantPackage.php @@ -0,0 +1,25 @@ +tenant_id; + + return $data; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php new file mode 100644 index 0000000..73deb49 --- /dev/null +++ b/app/Filament/Resources/TenantPackageResource/Pages/EditTenantPackage.php @@ -0,0 +1,25 @@ +getResource()::getUrl('index'); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php b/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php new file mode 100644 index 0000000..d0ec3f3 --- /dev/null +++ b/app/Filament/Resources/TenantPackageResource/Pages/ListTenantPackages.php @@ -0,0 +1,26 @@ +where('tenant_id', Auth::user()->tenant_id); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php b/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php new file mode 100644 index 0000000..bad7d57 --- /dev/null +++ b/app/Filament/Resources/TenantPackageResource/Pages/ViewTenantPackage.php @@ -0,0 +1,20 @@ +schema([ TextInput::make('name') ->label(__('admin.tenants.fields.name')) @@ -85,22 +87,25 @@ class TenantResource extends Resource public static function table(Table $table): Table { + \Illuminate\Support\Facades\Log::info('TenantResource table() method called'); + return $table ->columns([ Tables\Columns\TextColumn::make('id')->sortable(), Tables\Columns\TextColumn::make('name')->searchable()->sortable(), Tables\Columns\TextColumn::make('slug')->searchable(), Tables\Columns\TextColumn::make('contact_email'), - Tables\Columns\TextColumn::make('activeResellerPackage.name') + Tables\Columns\TextColumn::make('active_reseller_package_id') ->label(__('admin.tenants.fields.active_package')) ->badge() - ->color('success'), - Tables\Columns\TextColumn::make('remaining_events') + ->color('success') + ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->name ?? 'Kein aktives Package'), + Tables\Columns\TextColumn::make('active_reseller_package_id') ->label(__('admin.tenants.fields.remaining_events')) ->badge() ->color(fn ($state) => $state < 1 ? 'danger' : 'success') ->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0), - Tables\Columns\TextColumn::make('activeResellerPackage.expires_at') + Tables\Columns\TextColumn::make('active_reseller_package_id') ->dateTime() ->label(__('admin.tenants.fields.package_expires_at')) ->badge() @@ -178,6 +183,8 @@ class TenantResource extends Resource public static function getRelations(): array { + \Illuminate\Support\Facades\Log::info('TenantResource getRelations() method called'); + return [ TenantPackagesRelationManager::class, PackagePurchasesRelationManager::class, diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php new file mode 100644 index 0000000..b32ffda --- /dev/null +++ b/app/Filament/Resources/UserResource.php @@ -0,0 +1,132 @@ +schema([ + Section::make('Personal Information') + ->schema([ + TextInput::make('first_name') + ->required() + ->maxLength(255), + TextInput::make('last_name') + ->required() + ->maxLength(255), + TextInput::make('username') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + TextInput::make('email') + ->email() + ->required() + ->unique(ignoreRecord: true), + Textarea::make('address') + ->required() + ->rows(3), + TextInput::make('phone') + ->required() + ->tel(), + ]) + ->columns(2), + Section::make('Password') + ->schema([ + TextInput::make('password') + ->password() + ->required(fn (string $operation): bool => $operation === 'create') + ->dehydrated(fn (?string $state): bool => filled($state)) + ->same('password_confirmation'), + TextInput::make('password_confirmation') + ->password() + ->required(fn (string $operation): bool => $operation === 'create') + ->dehydrated(false), + ]) + ->columns(1) + ->visible(fn (): bool => Auth::user()?->id === Request::route('record')), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('fullName') + ->searchable(), + TextColumn::make('email') + ->searchable(), + TextColumn::make('username') + ->searchable(), + TextColumn::make('phone'), + TextColumn::make('tenant.name') + ->label('Tenant'), + TextColumn::make('email_verified_at') + ->dateTime() + ->sortable(), + ]) + ->filters([ + // + ]) + ->actions([ + ActionGroup::make([ + ViewAction::make(), + EditAction::make(), + ]), + ]) + ->bulkActions([ + BulkActionGroup::make([ + DeleteBulkAction::make(), + ]), + ]) + ->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Auth::user()->tenant_id)); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListUsers::route('/'), + 'edit' => Pages\EditUser::route('/{record}/edit'), + ]; + } +} \ No newline at end of file diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php new file mode 100644 index 0000000..e2dfedd --- /dev/null +++ b/app/Filament/Resources/UserResource/Pages/EditUser.php @@ -0,0 +1,34 @@ +getResource()::getUrl('index'); + } +} \ No newline at end of file diff --git a/app/Filament/Resources/UserResource/Pages/ListUsers.php b/app/Filament/Resources/UserResource/Pages/ListUsers.php new file mode 100644 index 0000000..5e787e4 --- /dev/null +++ b/app/Filament/Resources/UserResource/Pages/ListUsers.php @@ -0,0 +1,28 @@ +recordClasses(fn (User $record) => $record->id === auth()->id() ? 'border-2 border-blue-500' : '') + ->poll('30s'); + } +} \ No newline at end of file diff --git a/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php b/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php new file mode 100644 index 0000000..1d58f45 --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/Auth/EditProfile.php @@ -0,0 +1,41 @@ +schema([ + $this->getNameFormComponent(), + $this->getEmailFormComponent(), + TextInput::make('username') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(255), + Select::make('preferred_locale') + ->options([ + 'de' => 'Deutsch', + 'en' => 'English', + ]) + ->default('de') + ->required(), + $this->getPasswordFormComponent(), + $this->getPasswordConfirmationFormComponent(), + $this->getCurrentPasswordFormComponent(), + ]); + } +} \ No newline at end of file diff --git a/app/Filament/SuperAdmin/Pages/Auth/Login.php b/app/Filament/SuperAdmin/Pages/Auth/Login.php new file mode 100644 index 0000000..6a4787a --- /dev/null +++ b/app/Filament/SuperAdmin/Pages/Auth/Login.php @@ -0,0 +1,79 @@ +form->getState(); + + $credentials = $this->getCredentialsFromFormData($data); + + if (! Auth::attempt($credentials, $data['remember'] ?? false)) { + throw ValidationException::withMessages([ + 'data.email' => __('auth.failed'), + ]); + } + + $user = Auth::user(); + + if (! $user->email_verified_at) { + Auth::logout(); + + throw ValidationException::withMessages([ + 'data.email' => 'Your email address is not verified. Please check your email for a verification link.', + ]); + } + + // SuperAdmin-spezifisch: Prüfe auf SuperAdmin-Rolle, keine Tenant-Prüfung + if ($user->role !== 'superadmin') { + Auth::logout(); + + throw ValidationException::withMessages([ + 'data.email' => 'You do not have access to the SuperAdmin panel. Contact support.', + ]); + } + + session()->regenerate(); + + return $this->getLoginResponse(); + } + + protected function getCredentialsFromFormData(array $data): array + { + return [ + 'email' => $data['email'], + 'password' => $data['password'], + ]; + } + + public function getFormSchema(): array + { + return [ + TextInput::make('data.email') + ->label('Email') + ->email() + ->required() + ->autofocus(), + TextInput::make('data.password') + ->label('Password') + ->password() + ->required() + ->extraAttributes(['tabindex' => 2]), + Checkbox::make('data.remember') + ->label('Remember me'), + ]; + } +} \ 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 7b5d499..89c7d6b 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -53,11 +53,7 @@ class EventController extends Controller $tenant = Tenant::findOrFail($tenantId); } - if (!$tenant->canCreateEvent()) { - return response()->json([ - 'error' => 'No available package for creating events. Please purchase a package.', - ], 402); - } + // Package check is now handled by middleware $validated = $request->validated(); $tenantId = $tenant->id; diff --git a/app/Http/Controllers/Auth/MarketingRegisterController.php b/app/Http/Controllers/Auth/MarketingRegisterController.php new file mode 100644 index 0000000..ff5a546 --- /dev/null +++ b/app/Http/Controllers/Auth/MarketingRegisterController.php @@ -0,0 +1,94 @@ +has('package_id')) { + $package = Package::findOrFail($request->package_id); + } + + return view('marketing.register', compact('package')); + } + + /** + * Handle an incoming registration request. + * + * @throws \Illuminate\Validation\ValidationException + */ + public function store(Request $request): RedirectResponse + { + $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'username' => ['required', 'string', 'max:255', 'unique:' . User::class, 'alpha_dash'], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:' . User::class], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'address' => ['required', 'string'], + 'phone' => ['required', 'string', 'max:20'], + 'privacy_consent' => ['required', 'accepted'], + 'package_id' => ['nullable', 'exists:packages,id'], + ]); + + $user = User::create([ + 'name' => $request->name, + 'username' => $request->username, + 'email' => $request->email, + 'password' => Hash::make($request->password), + 'first_name' => $request->first_name, + 'last_name' => $request->last_name, + 'address' => $request->address, + 'phone' => $request->phone, + 'preferred_locale' => $request->preferred_locale ?? 'de', + ]); + + $tenant = Tenant::create([ + 'user_id' => $user->id, + 'name' => $request->name, + 'slug' => Str::slug($request->name . '-' . now()->timestamp), + 'email' => $request->email, + ]); + + // If package_id provided and free, assign immediately + if ($request->package_id) { + $package = Package::find($request->package_id); + if ($package && $package->price == 0) { + TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'active' => true, + 'expires_at' => now()->addYear(), // or based on package duration + ]); + } + } + + event(new Registered($user)); + + Auth::login($user); + + return $user->hasVerifiedEmail() + ? redirect()->intended('/admin') + : redirect()->route('verification.notice'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/Auth/RegisteredUserController.php b/app/Http/Controllers/Auth/RegisteredUserController.php index 08caeef..f535928 100644 --- a/app/Http/Controllers/Auth/RegisteredUserController.php +++ b/app/Http/Controllers/Auth/RegisteredUserController.php @@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Hash; use Illuminate\Validation\Rules; use Inertia\Inertia; use Inertia\Response; +use App\Models\Tenant; +use Illuminate\Support\Str; class RegisteredUserController extends Controller { @@ -42,6 +44,13 @@ class RegisteredUserController extends Controller 'password' => Hash::make($request->password), ]); + $tenant = Tenant::create([ + 'user_id' => $user->id, + 'name' => $request->name, + 'slug' => Str::slug($request->name . '-' . now()->timestamp), + 'email' => $request->email, + ]); + event(new Registered($user)); Auth::login($user); diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 3071ef6..fe36b24 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Stripe\Stripe; use Stripe\Checkout\Session; @@ -18,12 +19,16 @@ use PayPal\Rest\ApiContext; use PayPal\Auth\OAuthTokenCredential; use App\Models\Tenant; use App\Models\EventPurchase; +use App\Models\Package; +use App\Models\TenantPackage; +use App\Models\PackagePurchase; +use Illuminate\Support\Facades\Auth; class MarketingController extends Controller { public function __construct() { - \Stripe\Stripe::setApiKey(config('services.stripe.key')); + Stripe::setApiKey(config('services.stripe.key')); } public function index() @@ -53,74 +58,155 @@ class MarketingController extends Controller return redirect()->back()->with('success', 'Nachricht gesendet!'); } - public function checkout(Request $request, $package) + /** + * Handle package purchase flow. + */ + public function buyPackages(Request $request, $packageId) { - $packages = [ - 'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1], - 'standard' => ['name' => 'Standard', 'price' => 9900, 'events' => 10], // cents - 'premium' => ['name' => 'Premium', 'price' => 19900, 'events' => 50], - ]; + $package = Package::findOrFail($packageId); - if (!isset($packages[$package])) { - abort(404); + if (!Auth::check()) { + return redirect()->route('register', ['package_id' => $package->id]) + ->with('message', __('marketing.packages.register_required')); } - $pkg = $packages[$package]; + $user = Auth::user(); + if (!$user->email_verified_at) { + return redirect()->route('verification.notice') + ->with('message', __('auth.verification_required')); + } - if ($pkg['price'] == 0) { - // Free package: create tenant and event - $tenant = Tenant::create([ - 'name' => $request->input('tenant_name', 'New Tenant'), - 'slug' => Str::slug('new-' . now()), - 'email' => $request->input('email'), - 'events_remaining' => $pkg['events'], - ]); + $tenant = $user->tenant; + if (!$tenant) { + abort(500, 'Tenant not found'); + } - // Create initial event - $event = $tenant->events()->create([ - 'name' => $request->input('event_name', 'My Event'), - 'slug' => Str::slug($request->input('event_name', 'my-event')), - 'status' => 'active', - ]); + if ($package->price == 0) { + TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + ], + [ + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + ] + ); - $purchase = EventPurchase::create([ + PackagePurchase::create([ 'tenant_id' => $tenant->id, - 'events_purchased' => $pkg['events'], - 'amount' => 0, - 'currency' => 'EUR', - 'provider' => 'free', - 'status' => 'completed', + 'package_id' => $package->id, + 'provider_id' => 'free', + 'price' => 0, + 'type' => $package->type, 'purchased_at' => now(), + 'refunded' => false, ]); - return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.'); + return redirect('/admin')->with('success', __('marketing.packages.free_assigned')); } - $stripe = new \Stripe\StripeClient(config('services.stripe.secret')); + if ($request->input('provider') === 'paypal') { + return $this->paypalCheckout($request, $packageId); + } + + return $this->checkout($request, $packageId); + } + + /** + * Checkout for Stripe with auth metadata. + */ + public function checkout(Request $request, $packageId) + { + $package = Package::findOrFail($packageId); + $user = Auth::user(); + $tenant = $user->tenant; + + $stripe = new StripeClient(config('services.stripe.secret')); $session = $stripe->checkout->sessions->create([ 'payment_method_types' => ['card'], 'line_items' => [[ 'price_data' => [ 'currency' => 'eur', 'product_data' => [ - 'name' => $pkg['name'] . ' Package', + 'name' => $package->name, ], - 'unit_amount' => $pkg['price'], + 'unit_amount' => $package->price * 100, ], 'quantity' => 1, ]], 'mode' => 'payment', - 'success_url' => route('marketing.success', $package), - 'cancel_url' => route('marketing'), + 'success_url' => route('marketing.success', $packageId), + 'cancel_url' => route('packages'), 'metadata' => [ - 'package' => $package, - 'events' => $pkg['events'], + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'type' => $package->type, ], ]); return redirect($session->url, 303); } + /** + * PayPal checkout with auth metadata. + */ + public function paypalCheckout(Request $request, $packageId) + { + $package = Package::findOrFail($packageId); + $user = Auth::user(); + $tenant = $user->tenant; + + $apiContext = new ApiContext( + new OAuthTokenCredential( + config('services.paypal.client_id'), + config('services.paypal.secret') + ) + ); + + $payment = new Payment(); + $payer = new Payer(); + $payer->setPaymentMethod('paypal'); + + $amountObj = new Amount(); + $amountObj->setCurrency('EUR'); + $amountObj->setTotal($package->price); + + $transaction = new Transaction(); + $transaction->setAmount($amountObj); + + $redirectUrls = new RedirectUrls(); + $redirectUrls->setReturnUrl(route('marketing.success', $packageId)); + $redirectUrls->setCancelUrl(route('packages')); + + $customData = json_encode([ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'type' => $package->type, + ]); + + $payment->setIntent('sale') + ->setPayer($payer) + ->setTransactions([$transaction]) + ->setRedirectUrls($redirectUrls) + ->setNoteToPayer('Package: ' . $package->name) + ->setCustom($customData); + + try { + $payment->create($apiContext); + + session(['paypal_payment_id' => $payment->getId()]); + + return redirect($payment->getApprovalLink()); + } catch (\Exception $e) { + Log::error('PayPal checkout error: ' . $e->getMessage()); + return back()->with('error', 'Zahlung fehlgeschlagen'); + } + } + public function stripeCheckout($sessionId) { // Handle Stripe success @@ -154,82 +240,4 @@ class MarketingController extends Controller return view('marketing.blog-show', compact('post')); } - - public function paypalCheckout(Request $request, $package) - { - $packages = [ - 'basic' => ['name' => 'Basic', 'price' => 0, 'events' => 1], - 'standard' => ['name' => 'Standard', 'price' => 99, 'events' => 10], - 'premium' => ['name' => 'Premium', 'price' => 199, 'events' => 50], - ]; - - if (!isset($packages[$package])) { - abort(404); - } - - $pkg = $packages[$package]; - - if ($pkg['price'] == 0) { - // Free package: create tenant and event - $tenant = Tenant::create([ - 'name' => $request->input('tenant_name', 'New Tenant'), - 'slug' => Str::slug('new-' . now()), - 'email' => $request->input('email'), - 'events_remaining' => $pkg['events'], - ]); - - // Create initial event - $event = $tenant->events()->create([ - 'name' => $request->input('event_name', 'My Event'), - 'slug' => Str::slug($request->input('event_name', 'my-event')), - 'status' => 'active', - ]); - - $purchase = EventPurchase::create([ - 'tenant_id' => $tenant->id, - 'events_purchased' => $pkg['events'], - 'amount' => 0, - 'currency' => 'EUR', - 'provider' => 'free', - 'status' => 'completed', - 'purchased_at' => now(), - ]); - - return redirect("/admin/tenants/{$tenant->id}/edit")->with('success', 'Konto erstellt! Willkommen bei Fotospiel.'); - } - - $apiContext = new ApiContext( - new OAuthTokenCredential( - config('services.paypal.client_id'), - config('services.paypal.secret') - ) - ); - - $payment = new Payment(); - $payer = new Payer(); - $payer->setPaymentMethod('paypal'); - - $amountObj = new Amount(); - $amountObj->setCurrency('EUR'); - $amountObj->setTotal($pkg['price']); - - $transaction = new Transaction(); - $transaction->setAmount($amountObj); - - $redirectUrls = new RedirectUrls(); - $redirectUrls->setReturnUrl(route('marketing.success', $package)); - $redirectUrls->setCancelUrl(route('marketing')); - - $payment->setIntent('sale') - ->setPayer($payer) - ->setTransactions([$transaction]) - ->setRedirectUrls($redirectUrls); - - try { - $payment->create($apiContext); - return redirect($payment->getApprovalLink()); - } catch (Exception $e) { - return back()->with('error', 'Zahlung fehlgeschlagen'); - } - } } diff --git a/app/Http/Controllers/PayPalController.php b/app/Http/Controllers/PayPalController.php new file mode 100644 index 0000000..3eae170 --- /dev/null +++ b/app/Http/Controllers/PayPalController.php @@ -0,0 +1,235 @@ +client = PaypalServerSdkClientBuilder::init() + ->clientCredentialsAuthCredentials( + ClientCredentialsAuthCredentialsBuilder::init($clientId, $clientSecret) + ) + ->environment(config('app.env') === 'production' ? Environment::PRODUCTION : Environment::SANDBOX) + ->loggingConfiguration( + LoggingConfigurationBuilder::init() + ->level(LogLevel::INFO) + ->requestConfiguration(RequestLoggingConfigurationBuilder::init()->body(true)) + ->responseConfiguration(ResponseLoggingConfigurationBuilder::init()->headers(true)) + ) + ->build(); + } + + public function createOrder(Request $request) + { + $request->validate([ + 'tenant_id' => 'required|exists:tenants,id', + 'package_id' => 'required|exists:packages,id', + ]); + + $tenant = Tenant::findOrFail($request->tenant_id); + $package = Package::findOrFail($request->package_id); + + $ordersController = $this->client->getOrdersController(); + + $requestBody = OrderRequestBuilder::init(CheckoutPaymentIntent::CAPTURE) + ->purchaseUnits([ + PurchaseUnitRequestBuilder::init() + ->amount( + AmountWithBreakdownBuilder::init('EUR', number_format($package->price, 2, '.', '')) + ->build() + ) + ->description('Package: ' . $package->name) + ->customId($tenant->id . '_' . $package->id . '_endcustomer_event') + ->build() + ]) + ->applicationContext( + ApplicationContextBuilder::init() + ->shippingPreference(ShippingPreference::NO_SHIPPING) + ->userAction('PAY_NOW') + ->build() + ) + ->build(); + + $collect = [ + 'body' => $requestBody, + 'prefer' => 'return=representation' + ]; + + try { + $response = $ordersController->createOrder($collect); + + if ($response->statusCode === 201) { + $result = $response->result; + $approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href; + + return response()->json([ + 'id' => $result->id, + 'approve_url' => $approveLink, + ]); + } + + Log::error('PayPal order creation failed', ['response' => $response]); + return response()->json(['error' => 'Order creation failed'], 400); + } catch (\Exception $e) { + Log::error('PayPal order creation exception', ['error' => $e->getMessage()]); + return response()->json(['error' => 'Order creation failed'], 500); + } + } + + public function captureOrder(Request $request) + { + $request->validate(['order_id' => 'required']); + + $ordersController = $this->client->getOrdersController(); + + $collect = [ + 'id' => $request->order_id, + 'prefer' => 'return=representation' + ]; + + try { + $response = $ordersController->captureOrder($collect); + + if ($response->statusCode === 201) { + $result = $response->result; + $customId = $result->purchaseUnits[0]->customId ?? null; + + if ($customId) { + [$tenantId, $packageId, $type] = explode('_', $customId); + $tenant = Tenant::findOrFail($tenantId); + $package = Package::findOrFail($packageId); + + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $result->id, + 'price' => $result->purchaseUnits[0]->amount->value, + 'type' => $type ?? 'endcustomer_event', + 'purchased_at' => now(), + 'refunded' => false, + ]); + + Log::info('PayPal order captured and purchase created: ' . $result->id); + } + + return response()->json(['status' => 'captured', 'order' => $result]); + } + + Log::error('PayPal order capture failed', ['response' => $response]); + return response()->json(['error' => 'Capture failed'], 400); + } catch (\Exception $e) { + Log::error('PayPal order capture exception', ['error' => $e->getMessage()]); + return response()->json(['error' => 'Capture failed'], 500); + } + } + + public function createSubscription(Request $request) + { + $request->validate([ + 'tenant_id' => 'required|exists:tenants,id', + 'package_id' => 'required|exists:packages,id', + 'plan_id' => 'required', // PayPal plan ID for the package + ]); + + $tenant = Tenant::findOrFail($request->tenant_id); + $package = Package::findOrFail($request->package_id); + + $subscriptionsController = $this->client->getSubscriptionsController(); + + $requestBody = SubscriptionRequestBuilder::init() + ->planId($request->plan_id) + ->subscriber( + SubscriberBuilder::init() + ->name( + NameBuilder::init() + ->givenName($tenant->name ?? 'Tenant') + ->build() + ) + ->emailAddress($tenant->email) + ->build() + ) + ->customId($tenant->id . '_' . $package->id . '_reseller_subscription') + ->applicationContext( + ApplicationContextSubscriptionBuilder::init() + ->shippingPreference(ShippingPreference::NO_SHIPPING) + ->userAction('SUBSCRIBE_NOW') + ->build() + ) + ->build(); + + $collect = [ + 'body' => $requestBody, + 'prefer' => 'return=representation' + ]; + + try { + $response = $subscriptionsController->createSubscription($collect); + + if ($response->statusCode === 201) { + $result = $response->result; + $subscriptionId = $result->id; + + TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + 'active' => true, + ]); + + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $subscriptionId, + 'price' => $package->price, + 'type' => 'reseller_subscription', + 'purchased_at' => now(), + ]); + + $approveLink = collect($result->links)->first(fn($link) => $link->rel === 'approve')?->href; + + return response()->json([ + 'subscription_id' => $subscriptionId, + 'approve_url' => $approveLink, + ]); + } + + Log::error('PayPal subscription creation failed', ['response' => $response]); + return response()->json(['error' => 'Subscription creation failed'], 400); + } catch (\Exception $e) { + Log::error('PayPal subscription creation exception', ['error' => $e->getMessage()]); + return response()->json(['error' => 'Subscription creation failed'], 500); + } + } +} \ No newline at end of file diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php index b77198f..d0883eb 100644 --- a/app/Http/Controllers/PayPalWebhookController.php +++ b/app/Http/Controllers/PayPalWebhookController.php @@ -16,32 +16,60 @@ class PayPalWebhookController extends Controller $payerEmail = $input['payer_email'] ?? null; $paymentStatus = $input['payment_status'] ?? null; $mcGross = $input['mc_gross'] ?? 0; - $packageId = $input['custom'] ?? null; + $custom = $input['custom'] ?? null; if ($paymentStatus === 'Completed' && $mcGross > 0) { // Verify IPN with PayPal (simplified; use SDK for full verification) // $verified = $this->verifyIPN($input); - // Find or create tenant (for public checkout, perhaps create new or use session) - // For now, assume tenant_id from custom or session - $tenantId = $packageId ? Tenant::where('slug', $packageId)->first()->id ?? 1 : 1; + // Parse custom for user_id or tenant_id + $data = json_decode($custom, true); + $userId = $data['user_id'] ?? null; + $tenantId = $data['tenant_id'] ?? null; + $packageId = $data['package_id'] ?? null; - // Create purchase and increment credits - $purchase = EventPurchase::create([ - 'tenant_id' => $tenantId, // Implement tenant resolution - 'events_purchased' => $mcGross / 49, // Example: 49€ per event credit - 'amount' => $mcGross, - 'currency' => $input['mc_currency'] ?? 'EUR', - 'provider' => 'paypal', - 'external_receipt_id' => $ipnMessage, - 'status' => 'completed', + if ($userId && !$tenantId) { + $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); + if ($tenant) { + $tenantId = $tenant->id; + } else { + Log::error('Tenant not found for user_id in PayPal IPN: ' . $userId); + return response('OK', 200); + } + } + + if (!$tenantId || !$packageId) { + Log::error('Missing tenant or package in PayPal IPN custom data'); + return response('OK', 200); + } + + // Create PackagePurchase + \App\Models\PackagePurchase::create([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'provider_id' => $ipnMessage, + 'price' => $mcGross, + 'type' => $data['type'] ?? 'reseller_subscription', 'purchased_at' => now(), + 'refunded' => false, ]); - $tenant = Tenant::find($tenantId); - $tenant->incrementCredits($purchase->events_purchased, 'paypal_purchase', 'PayPal IPN', $purchase->id); + // Update TenantPackage if subscription + if ($data['type'] ?? '' === 'reseller_subscription') { + \App\Models\TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + ], + [ + 'active' => true, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + ] + ); + } - Log::info('PayPal IPN processed', $input); + Log::info('PayPal IPN processed for tenant ' . $tenantId . ', package ' . $packageId, $input); } return response('OK', 200); diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..ceddc9a --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,67 @@ + $request->user(), + ]); + } + + /** + * Update the user's profile information. + */ + public function update(Request $request, User $user): RedirectResponse + { + // Authorized via auth middleware + + $request->validate([ + 'name' => 'required|string|max:255', + 'username' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:users,username,' . $user->id], + 'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email,' . $user->id], + 'first_name' => ['required', 'string', 'max:255'], + 'last_name' => ['required', 'string', 'max:255'], + 'address' => ['required', 'string'], + 'phone' => ['required', 'string', 'max:20'], + ]); + + $user->update($request->only([ + 'name', 'username', 'email', 'first_name', 'last_name', 'address', 'phone' + ])); + + return back()->with('status', 'profile-updated'); + } + + /** + * Update the user's password. + */ + public function updatePassword(Request $request, User $user): RedirectResponse + { + // Authorized via auth middleware + + $request->validate([ + 'current_password' => ['required', 'current_password'], + 'password' => ['required', 'confirmed', Rules\Password::defaults()], + ]); + + $user->update([ + 'password' => Hash::make($request->password), + ]); + + return back()->with('status', 'password-updated'); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/StripeController.php b/app/Http/Controllers/StripeController.php new file mode 100644 index 0000000..104802b --- /dev/null +++ b/app/Http/Controllers/StripeController.php @@ -0,0 +1,107 @@ +validate([ + 'tenant_id' => 'required|exists:tenants,id', + 'package_id' => 'required|exists:packages,id', + 'amount' => 'required|numeric|min:0', + ]); + + $tenant = Tenant::findOrFail($request->tenant_id); + $package = \App\Models\Package::findOrFail($request->package_id); + + $paymentIntent = PaymentIntent::create([ + 'amount' => $request->amount * 100, // cents + 'currency' => 'eur', + 'metadata' => [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'type' => 'endcustomer_event', // or reseller + ], + ]); + + return response()->json([ + 'client_secret' => $paymentIntent->client_secret, + ]); + } + + public function createSubscription(Request $request) + { + $request->validate([ + 'tenant_id' => 'required|exists:tenants,id', + 'package_id' => 'required|exists:packages,id', + 'payment_method_id' => 'required', + ]); + + $tenant = Tenant::findOrFail($request->tenant_id); + $package = \App\Models\Package::findOrFail($request->package_id); + + $subscription = Subscription::create([ + 'customer' => $tenant->stripe_customer_id ?? $this->createCustomer($tenant), + 'items' => [[ + 'price' => $package->stripe_price_id, // Assume package has stripe_price_id + ]], + 'payment_method' => $request->payment_method_id, + 'default_payment_method' => $request->payment_method_id, + 'expand' => ['latest_invoice.payment_intent'], + 'metadata' => [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'type' => 'reseller_subscription', + ], + ]); + + // Create TenantPackage and PackagePurchase + \App\Models\TenantPackage::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), + 'active' => true, + ]); + + PackagePurchase::create([ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'provider_id' => $subscription->id, + 'price' => $package->price, + 'type' => 'reseller_subscription', + 'purchased_at' => now(), + ]); + + return response()->json([ + 'subscription_id' => $subscription->id, + 'client_secret' => $subscription->latest_invoice->payment_intent->client_secret ?? null, + ]); + } + + private function createCustomer(Tenant $tenant) + { + $customer = \Stripe\Customer::create([ + 'email' => $tenant->email, + 'metadata' => ['tenant_id' => $tenant->id], + ]); + + $tenant->update(['stripe_customer_id' => $customer->id]); + + return $customer->id; + } +} \ No newline at end of file diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 52965c5..a848988 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -2,90 +2,169 @@ namespace App\Http\Controllers; -use App\Models\EventPurchase; -use App\Models\Tenant; +use App\Models\PackagePurchase; +use App\Models\TenantPackage; use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Stripe\Stripe; +use Stripe\Webhook; class StripeWebhookController extends Controller { - public function handle(Request $request) + public function __construct() + { + Stripe::setApiKey(config('services.stripe.secret')); + } + + public function handleWebhook(Request $request) { $payload = $request->getContent(); - $sig = $request->header('Stripe-Signature'); - $secret = config('services.stripe.webhook'); + $sigHeader = $request->header('Stripe-Signature'); + $endpointSecret = config('services.stripe.webhook_secret'); - if (!$secret || !$sig) { - abort(400, 'Missing signature'); + try { + $event = Webhook::constructEvent($payload, $sigHeader, $endpointSecret); + } catch (\Exception $e) { + Log::error('Stripe webhook signature verification failed: ' . $e->getMessage()); + return response()->json(['error' => 'Invalid signature'], 400); } - $expectedSig = 'v1=' . hash_hmac('sha256', $payload, $secret); + switch ($event['type']) { + case 'payment_intent.succeeded': + $paymentIntent = $event['data']['object']; + $this->handlePaymentIntentSucceeded($paymentIntent); + break; - if (!hash_equals($expectedSig, $sig)) { - abort(400, 'Invalid signature'); + case 'invoice.payment_succeeded': + $invoice = $event['data']['object']; + $this->handleInvoicePaymentSucceeded($invoice); + break; + + case 'invoice.payment_failed': + $invoice = $event['data']['object']; + $this->handleInvoicePaymentFailed($invoice); + break; + + default: + Log::info('Unhandled Stripe event type: ' . $event['type']); } - $event = json_decode($payload, true); + return response()->json(['status' => 'success']); + } - if (json_last_error() !== JSON_ERROR_NONE) { - Log::error('Invalid JSON in Stripe webhook: ' . json_last_error_msg()); - return response('', 200); + private function handlePaymentIntentSucceeded($paymentIntent) + { + $metadata = $paymentIntent['metadata']; + if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) { + Log::warning('Missing metadata in Stripe payment intent: ' . $paymentIntent['id']); + return; } - if ($event['type'] === 'checkout.session.completed') { - $session = $event['data']['object']; - $receiptId = $session['id']; + $userId = $metadata['user_id'] ?? null; + $tenantId = $metadata['tenant_id'] ?? null; + $packageId = $metadata['package_id']; + $type = $metadata['type'] ?? 'endcustomer_event'; - // Idempotency check - if (EventPurchase::where('external_receipt_id', $receiptId)->exists()) { - return response('', 200); + if ($userId && !$tenantId) { + $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); + if ($tenant) { + $tenantId = $tenant->id; + } else { + Log::error('Tenant not found for user_id: ' . $userId); + return; } - - $tenantId = $session['metadata']['tenant_id'] ?? null; - - if (!$tenantId) { - Log::warning('No tenant_id in Stripe metadata', ['receipt_id' => $receiptId]); - // Dispatch job for retry or manual resolution - \App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig); - return response('', 200); - } - - $tenant = Tenant::find($tenantId); - - if (!$tenant) { - Log::error('Tenant not found for Stripe webhook', ['tenant_id' => $tenantId]); - \App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig); - return response('', 200); - } - - $amount = $session['amount_total'] / 100; - $currency = $session['currency']; - $eventsPurchased = (int) ($session['metadata']['events_purchased'] ?? 1); - - DB::transaction(function () use ($tenant, $amount, $currency, $eventsPurchased, $receiptId) { - $purchase = EventPurchase::create([ - 'tenant_id' => $tenant->id, - 'events_purchased' => $eventsPurchased, - 'amount' => $amount, - 'currency' => $currency, - 'provider' => 'stripe', - 'external_receipt_id' => $receiptId, - 'status' => 'completed', - 'purchased_at' => now(), - ]); - - $tenant->incrementCredits($eventsPurchased, 'purchase', null, $purchase->id); - }); - - Log::info('Processed Stripe purchase', ['receipt_id' => $receiptId, 'tenant_id' => $tenantId]); - } else { - // For other event types, log or dispatch job if needed - Log::info('Unhandled Stripe event', ['type' => $event['type']]); - // Optionally dispatch job for processing other events - \App\Jobs\ValidateStripeWebhookJob::dispatch($payload, $sig); } - return response('', 200); + if (!$tenantId) { + Log::error('No tenant_id found for Stripe payment intent: ' . $paymentIntent['id']); + return; + } + + // Create PackagePurchase for one-off payment + \App\Models\PackagePurchase::create([ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'provider_id' => $paymentIntent['id'], + 'price' => $paymentIntent['amount_received'] / 100, + 'type' => $type, + 'purchased_at' => now(), + 'refunded' => false, + ]); + + if ($type === 'endcustomer_event') { + // For event packages, assume event_id from metadata or handle separately + // TODO: Link to specific event if provided + } + + Log::info('Package purchase created via Stripe payment intent: ' . $paymentIntent['id'] . ' for tenant ' . $tenantId); + } + + private function handleInvoicePaymentSucceeded($invoice) + { + $subscription = $invoice['subscription']; + $metadata = $invoice['metadata']; + + if (!$metadata['user_id'] && !$metadata['tenant_id'] || !$metadata['package_id']) { + Log::warning('Missing metadata in Stripe invoice: ' . $invoice['id']); + return; + } + + $userId = $metadata['user_id'] ?? null; + $tenantId = $metadata['tenant_id'] ?? null; + $packageId = $metadata['package_id']; + + if ($userId && !$tenantId) { + $tenant = \App\Models\Tenant::where('user_id', $userId)->first(); + if ($tenant) { + $tenantId = $tenant->id; + } else { + Log::error('Tenant not found for user_id: ' . $userId); + return; + } + } + + if (!$tenantId) { + Log::error('No tenant_id found for Stripe invoice: ' . $invoice['id']); + return; + } + + // Update or create TenantPackage for subscription + \App\Models\TenantPackage::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + ], + [ + 'purchased_at' => now(), + 'expires_at' => now()->addYear(), // Renew annually + 'active' => true, + ] + ); + + // Create or update PackagePurchase + \App\Models\PackagePurchase::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'package_id' => $packageId, + 'provider_id' => $subscription, + ], + [ + 'price' => $invoice['amount_paid'] / 100, + 'type' => 'reseller_subscription', + 'purchased_at' => now(), + 'refunded' => false, + ] + ); + + Log::info('Subscription renewed via Stripe invoice: ' . $invoice['id'] . ' for tenant ' . $tenantId); + } + + private function handleInvoicePaymentFailed($invoice) + { + $subscription = $invoice['subscription']; + Log::warning('Stripe invoice payment failed: ' . $invoice['id'] . ' for subscription ' . $subscription); + + // TODO: Deactivate package or notify tenant + // e.g., TenantPackage::where('provider_id', $subscription)->update(['active' => false]); } } \ No newline at end of file diff --git a/app/Http/Middleware/PackageMiddleware.php b/app/Http/Middleware/PackageMiddleware.php new file mode 100644 index 0000000..0be6f3e --- /dev/null +++ b/app/Http/Middleware/PackageMiddleware.php @@ -0,0 +1,91 @@ +attributes->get('tenant'); + if (! $tenant instanceof Tenant) { + $tenant = $this->resolveTenant($request); + $request->attributes->set('tenant', $tenant); + $request->attributes->set('tenant_id', $tenant->id); + $request->merge([ + 'tenant' => $tenant, + 'tenant_id' => $tenant->id, + ]); + } + + if ($this->requiresPackageCheck($request) && !$this->canPerformAction($request, $tenant)) { + return response()->json([ + 'error' => 'Package limits exceeded. Please purchase or upgrade a package.', + ], 402); + } + + return $next($request); + } + + private function requiresPackageCheck(Request $request): bool + { + return $request->isMethod('post') && ( + $request->routeIs('api.v1.tenant.events.store') || + $request->routeIs('api.v1.tenant.photos.store') // Assuming photo upload route + ); + } + + private function canPerformAction(Request $request, Tenant $tenant): bool + { + if ($request->routeIs('api.v1.tenant.events.store')) { + // Check tenant package for event creation + $resellerPackage = $tenant->activeResellerPackage(); + if ($resellerPackage) { + return $resellerPackage->used_events < $resellerPackage->package->max_events_per_year; + } + return false; + } + + if ($request->routeIs('api.v1.tenant.photos.store')) { + $eventId = $request->input('event_id'); + if (!$eventId) { + return false; + } + $event = Event::findOrFail($eventId); + if ($event->tenant_id !== $tenant->id) { + return false; + } + $eventPackage = $event->eventPackage; + if (!$eventPackage) { + return false; + } + return $eventPackage->used_photos < $eventPackage->package->max_photos; + } + + return true; + } + + private function resolveTenant(Request $request): Tenant + { + $user = $request->user(); + if ($user && isset($user->tenant) && $user->tenant instanceof Tenant) { + return $user->tenant; + } + + $tenantId = $request->attributes->get('tenant_id'); + if (! $tenantId && $user && isset($user->tenant_id)) { + $tenantId = $user->tenant_id; + } + + if (! $tenantId) { + abort(401, 'Unauthenticated'); + } + + return Tenant::findOrFail($tenantId); + } +} \ No newline at end of file diff --git a/app/Http/Requests/Auth/LoginRequest.php b/app/Http/Requests/Auth/LoginRequest.php index feef498..bf7945c 100644 --- a/app/Http/Requests/Auth/LoginRequest.php +++ b/app/Http/Requests/Auth/LoginRequest.php @@ -26,7 +26,7 @@ class LoginRequest extends FormRequest public function rules(): array { return [ - 'email' => ['required', 'string', 'email'], + 'login' => ['required', 'string'], 'password' => ['required', 'string'], ]; } @@ -40,11 +40,14 @@ class LoginRequest extends FormRequest { $this->ensureIsNotRateLimited(); - if (! Auth::attempt($this->only('email', 'password'), $this->boolean('remember'))) { + $credentials = $this->only('login', 'password'); + $credentials['login'] = $this->input('login'); + + if (! Auth::attempt($credentials, $this->boolean('remember'))) { RateLimiter::hit($this->throttleKey()); throw ValidationException::withMessages([ - 'email' => __('auth.failed'), + 'login' => __('auth.failed'), ]); } @@ -79,7 +82,7 @@ class LoginRequest extends FormRequest */ public function throttleKey(): string { - return $this->string('email') + return $this->string('login') ->lower() ->append('|'.$this->ip()) ->transliterate() diff --git a/app/Mail/Welcome.php b/app/Mail/Welcome.php new file mode 100644 index 0000000..07d6ed0 --- /dev/null +++ b/app/Mail/Welcome.php @@ -0,0 +1,42 @@ + $this->user, + ], + ); + } + + public function attachments(): array + { + return []; + } +} \ No newline at end of file diff --git a/app/Models/Event.php b/app/Models/Event.php index 75c54b8..21093e3 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -18,6 +18,8 @@ class Event extends Model 'date' => 'datetime', 'settings' => 'array', 'is_active' => 'boolean', + 'name' => 'array', + 'description' => 'array', ]; public function tenant(): BelongsTo diff --git a/app/Models/Package.php b/app/Models/Package.php index 2714f5b..15f3b96 100644 --- a/app/Models/Package.php +++ b/app/Models/Package.php @@ -40,13 +40,7 @@ class Package extends Model '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), - ); - } + // features handled by $casts = ['features' => 'array'] public function eventPackages(): HasMany { diff --git a/app/Models/PackagePurchase.php b/app/Models/PackagePurchase.php index 35eb569..87eabf1 100644 --- a/app/Models/PackagePurchase.php +++ b/app/Models/PackagePurchase.php @@ -78,6 +78,10 @@ class PackagePurchase extends Model parent::boot(); static::creating(function ($purchase) { + if (!$purchase->tenant_id) { + throw new \Exception('Tenant ID is required for package purchases.'); + } + if (!$purchase->purchased_at) { $purchase->purchased_at = now(); } diff --git a/app/Models/Task.php b/app/Models/Task.php index 1b2ea44..9741376 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -18,6 +18,9 @@ class Task extends Model protected $casts = [ 'due_date' => 'datetime', 'is_completed' => 'bool', + 'title' => 'array', + 'description' => 'array', + 'example_text' => 'array', ]; public function emotion(): BelongsTo diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 9ea32e0..b3d95e9 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -7,6 +7,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Facades\DB; class Tenant extends Model @@ -51,9 +53,9 @@ class Tenant extends Model return $this->hasMany(TenantPackage::class); } - public function activeResellerPackage() + public function activeResellerPackage(): HasOne { - return $this->tenantPackages()->where('active', true)->first(); + return $this->hasOne(TenantPackage::class)->where('active', true); } public function canCreateEvent(): bool @@ -93,4 +95,9 @@ class Tenant extends Model get: fn () => $this->activeResellerPackage() !== null, ); } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index a68d373..1e563bf 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,13 +3,15 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Contracts\Auth\MustVerifyEmail; +use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasOne; use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable; @@ -25,6 +27,10 @@ class User extends Authenticatable 'password', 'username', 'preferred_locale', + 'first_name', + 'last_name', + 'address', + 'phone', ]; /** @@ -50,8 +56,15 @@ class User extends Authenticatable ]; } - public function tenant(): BelongsTo + protected function fullName(): Attribute { - return $this->belongsTo(Tenant::class); + return Attribute::make( + get: fn () => $this->first_name . ' ' . $this->last_name, + ); + } + + public function tenant(): HasOne + { + return $this->hasOne(Tenant::class); } } diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index bfd0360..e3e0e54 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -32,12 +32,12 @@ class AdminPanelProvider extends PanelProvider ->default() ->id('admin') ->path('admin') - ->login() + ->login(\App\Filament\Pages\Auth\Login::class) ->colors([ 'primary' => Color::Pink, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') + ->discoverPages(in: app_path('Filament/Admin/Pages'), for: 'App\\Filament\\Admin\\Pages') ->pages([ Pages\Dashboard::class, ]) @@ -61,8 +61,10 @@ class AdminPanelProvider extends PanelProvider Authenticate::class, ]) ->resources([ - // Blog-Resources moved to SuperAdminPanel + \App\Filament\Resources\UserResource::class, + \App\Filament\Resources\TenantPackageResource::class, ]) + ->tenant(\App\Models\Tenant::class) // Remove blog models as they are global and handled in SuperAdmin ; } diff --git a/app/Providers/Filament/SuperAdminPanelProvider.php b/app/Providers/Filament/SuperAdminPanelProvider.php index 62c6c6c..021a1af 100644 --- a/app/Providers/Filament/SuperAdminPanelProvider.php +++ b/app/Providers/Filament/SuperAdminPanelProvider.php @@ -19,7 +19,6 @@ use Illuminate\Session\Middleware\StartSession; use Illuminate\View\Middleware\ShareErrorsFromSession; use App\Filament\Resources\LegalPageResource; use App\Filament\Resources\TenantResource; -use App\Filament\Pages\SuperAdminProfile; use App\Models\User; use App\Models\Tenant; use App\Models\BlogPost; @@ -30,28 +29,31 @@ use App\Filament\Widgets\TopTenantsByUploads; use Stephenjude\FilamentBlog\Filament\Resources\CategoryResource; use Stephenjude\FilamentBlog\Filament\Resources\PostResource; use Stephenjude\FilamentBlog\BlogPlugin; +use Illuminate\Support\Facades\Log; class SuperAdminPanelProvider extends PanelProvider { public function panel(Panel $panel): Panel { + \Illuminate\Support\Facades\Log::info('SuperAdminPanelProvider panel method called'); + return $panel ->default() ->id('superadmin') ->path('super-admin') - ->login() ->colors([ 'primary' => Color::Pink, ]) ->discoverResources(in: app_path('Filament/Resources'), for: 'App\\Filament\\Resources') - ->discoverPages(in: app_path('Filament/Pages'), for: 'App\\Filament\\Pages') + ->discoverPages(in: app_path('Filament/SuperAdmin/Pages'), for: 'App\\Filament\\SuperAdmin\\Pages') ->pages([ Pages\Dashboard::class, ]) + ->login(\App\Filament\SuperAdmin\Pages\Auth\Login::class) ->plugin( BlogPlugin::make() ) - ->profile(SuperAdminProfile::class) + ->profile() ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->widgets([ Widgets\AccountWidget::class, @@ -74,13 +76,10 @@ class SuperAdminPanelProvider extends PanelProvider Authenticate::class, ]) ->resources([ - TenantResource::class, + // Temporär deaktiviert: TenantResource - verdächtigt für frühen Fehler + // TenantResource::class, LegalPageResource::class, ]) - ->authMiddleware([ - Authenticate::class, - 'superadmin.auth', - ]) ->authGuard('web') // SuperAdmin-Zugriff durch custom Middleware, globale Sichtbarkeit ohne Tenant-Isolation // Blog-Resources werden durch das Plugin-ServiceProvider automatisch registriert diff --git a/bootstrap/app.php b/bootstrap/app.php index 7a2ebe6..cf39485 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -22,7 +22,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'tenant.token' => TenantTokenGuard::class, 'tenant.isolation' => TenantIsolation::class, - 'credit.check' => CreditCheckMiddleware::class, + 'package.check' => \App\Http\Middleware\PackageMiddleware::class, 'locale' => \App\Http\Middleware\SetLocale::class, 'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class, ]); diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 60faac1..aeef057 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -4,4 +4,5 @@ return [ App\Providers\AppServiceProvider::class, Stephenjude\FilamentBlog\FilamentBlogServiceProvider::class, App\Providers\Filament\SuperAdminPanelProvider::class, + App\Providers\Filament\AdminPanelProvider::class, ]; diff --git a/composer.json b/composer.json index 923c5e3..796b2a1 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "laravel/sanctum": "^4.2", "laravel/tinker": "^2.10.1", "laravel/wayfinder": "^0.1.9", - "paypal/rest-api-sdk-php": "^1.6", + "paypal/paypal-server-sdk": "^1.1", "simplesoftwareio/simple-qrcode": "^4.2", "spatie/laravel-translatable": "^6.11", "stephenjude/filament-blog": "*", diff --git a/composer.lock b/composer.lock index 9a3fbb3..d8afc33 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": "cb0adb8c2149ab0ab72bdc3b0b7ee635", + "content-hash": "c7c9c8d3a298a4a78d257a1674cd117d", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -72,6 +72,222 @@ }, "time": "2025-07-30T15:45:57+00:00" }, + { + "name": "apimatic/core", + "version": "0.3.14", + "source": { + "type": "git", + "url": "https://github.com/apimatic/core-lib-php.git", + "reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apimatic/core-lib-php/zipball/c3eaad6cf0c00b793ce6d9bee8b87176247da582", + "reference": "c3eaad6cf0c00b793ce6d9bee8b87176247da582", + "shasum": "" + }, + "require": { + "apimatic/core-interfaces": "~0.1.5", + "apimatic/jsonmapper": "^3.1.1", + "ext-curl": "*", + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "php": "^7.2 || ^8.0", + "php-jsonpointer/php-jsonpointer": "^3.0.2", + "psr/log": "^1.1.4 || ^2.0.0 || ^3.0.0" + }, + "require-dev": { + "phan/phan": "5.4.5", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Core\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Core logic and the utilities for the Apimatic's PHP SDK", + "homepage": "https://github.com/apimatic/core-lib-php", + "keywords": [ + "apimatic", + "core", + "corelib", + "php" + ], + "support": { + "issues": "https://github.com/apimatic/core-lib-php/issues", + "source": "https://github.com/apimatic/core-lib-php/tree/0.3.14" + }, + "time": "2025-02-27T06:03:30+00:00" + }, + { + "name": "apimatic/core-interfaces", + "version": "0.1.5", + "source": { + "type": "git", + "url": "https://github.com/apimatic/core-interfaces-php.git", + "reference": "b4f1bffc8be79584836f70af33c65e097eec155c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apimatic/core-interfaces-php/zipball/b4f1bffc8be79584836f70af33c65e097eec155c", + "reference": "b4f1bffc8be79584836f70af33c65e097eec155c", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CoreInterfaces\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Definition of the behavior of apimatic/core, apimatic/unirest-php and Apimatic's PHP SDK", + "homepage": "https://github.com/apimatic/core-interfaces-php", + "keywords": [ + "apimatic", + "core", + "corelib", + "interface", + "php", + "unirest" + ], + "support": { + "issues": "https://github.com/apimatic/core-interfaces-php/issues", + "source": "https://github.com/apimatic/core-interfaces-php/tree/0.1.5" + }, + "time": "2024-05-09T06:32:07+00:00" + }, + { + "name": "apimatic/jsonmapper", + "version": "3.1.6", + "source": { + "type": "git", + "url": "https://github.com/apimatic/jsonmapper.git", + "reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apimatic/jsonmapper/zipball/c6cc21bd56bfe5d5822bbd08f514be465c0b24e7", + "reference": "c6cc21bd56bfe5d5822bbd08f514be465c0b24e7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^5.6 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "squizlabs/php_codesniffer": "^3.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "apimatic\\jsonmapper\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "OSL-3.0" + ], + "authors": [ + { + "name": "Christian Weiske", + "email": "christian.weiske@netresearch.de", + "homepage": "http://www.netresearch.de/", + "role": "Developer" + }, + { + "name": "Mehdi Jaffery", + "email": "mehdi.jaffery@apimatic.io", + "homepage": "http://apimatic.io/", + "role": "Developer" + } + ], + "description": "Map nested JSON structures onto PHP classes", + "support": { + "email": "mehdi.jaffery@apimatic.io", + "issues": "https://github.com/apimatic/jsonmapper/issues", + "source": "https://github.com/apimatic/jsonmapper/tree/3.1.6" + }, + "time": "2024-11-28T09:15:32+00:00" + }, + { + "name": "apimatic/unirest-php", + "version": "4.0.7", + "source": { + "type": "git", + "url": "https://github.com/apimatic/unirest-php.git", + "reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/apimatic/unirest-php/zipball/bdfd5f27c105772682c88ed671683f1bd93f4a3c", + "reference": "bdfd5f27c105772682c88ed671683f1bd93f4a3c", + "shasum": "" + }, + "require": { + "apimatic/core-interfaces": "^0.1.0", + "ext-curl": "*", + "ext-json": "*", + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "phan/phan": "5.4.2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Unirest\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mashape", + "email": "opensource@mashape.com", + "homepage": "https://www.mashape.com", + "role": "Developer" + }, + { + "name": "APIMATIC", + "email": "opensource@apimatic.io", + "homepage": "https://www.apimatic.io", + "role": "Developer" + } + ], + "description": "Unirest PHP", + "homepage": "https://github.com/apimatic/unirest-php", + "keywords": [ + "client", + "curl", + "http", + "https", + "rest" + ], + "support": { + "email": "opensource@apimatic.io", + "issues": "https://github.com/apimatic/unirest-php/issues", + "source": "https://github.com/apimatic/unirest-php/tree/4.0.7" + }, + "time": "2025-06-17T09:09:48+00:00" + }, { "name": "bacon/bacon-qr-code", "version": "2.0.8", @@ -564,6 +780,83 @@ ], "time": "2024-07-16T11:13:48+00:00" }, + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, { "name": "danharrin/date-format-converter", "version": "v0.3.1", @@ -1386,6 +1679,43 @@ }, "time": "2025-09-04T14:12:50+00:00" }, + { + "name": "filament/spatie-laravel-media-library-plugin", + "version": "v4.0.7", + "source": { + "type": "git", + "url": "https://github.com/filamentphp/spatie-laravel-media-library-plugin.git", + "reference": "fc15d6a60a3ff564fbdaf6588c55dab6c931d67a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filamentphp/spatie-laravel-media-library-plugin/zipball/fc15d6a60a3ff564fbdaf6588c55dab6c931d67a", + "reference": "fc15d6a60a3ff564fbdaf6588c55dab6c931d67a", + "shasum": "" + }, + "require": { + "filament/support": "self.version", + "php": "^8.2", + "spatie/laravel-medialibrary": "^11.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Filament\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Filament support for `spatie/laravel-medialibrary`.", + "homepage": "https://github.com/filamentphp/filament", + "support": { + "issues": "https://github.com/filamentphp/filament/issues", + "source": "https://github.com/filamentphp/filament" + }, + "time": "2025-09-01T09:39:21+00:00" + }, { "name": "filament/spatie-laravel-tags-plugin", "version": "v3.3.30", @@ -3641,6 +3971,84 @@ ], "time": "2025-07-17T05:12:15+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.3" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^12.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-07-17T11:15:13+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", @@ -4445,57 +4853,104 @@ "time": "2024-05-08T12:36:18+00:00" }, { - "name": "paypal/rest-api-sdk-php", - "version": "v1.6.4", + "name": "paypal/paypal-server-sdk", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/paypal/PayPal-PHP-SDK.git", - "reference": "06837d290c4906578cfd92786412dff330a1429c" + "url": "https://github.com/paypal/PayPal-PHP-Server-SDK.git", + "reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paypal/PayPal-PHP-SDK/zipball/06837d290c4906578cfd92786412dff330a1429c", - "reference": "06837d290c4906578cfd92786412dff330a1429c", + "url": "https://api.github.com/repos/paypal/PayPal-PHP-Server-SDK/zipball/3964c1732b1815fa8cf8aee37069ccc4e95d9572", + "reference": "3964c1732b1815fa8cf8aee37069ccc4e95d9572", "shasum": "" }, "require": { + "apimatic/core": "~0.3.13", + "apimatic/core-interfaces": "~0.1.5", + "apimatic/unirest-php": "^4.0.6", "ext-curl": "*", "ext-json": "*", - "php": ">=5.3.0" + "php": "^7.2 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "3.7.*" + "phan/phan": "5.4.5", + "squizlabs/php_codesniffer": "^3.5" }, "type": "library", "autoload": { - "psr-0": { - "PayPal": "lib/" + "psr-4": { + "PaypalServerSdkLib\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "Apache2" + "MIT" + ], + "description": "PayPal's SDK for interacting with the REST APIs", + "homepage": "https://github.com/paypal/PayPal-PHP-Server-SDK", + "support": { + "issues": "https://github.com/paypal/PayPal-PHP-Server-SDK/issues", + "source": "https://github.com/paypal/PayPal-PHP-Server-SDK/tree/1.1.0" + }, + "time": "2025-05-27T17:46:31+00:00" + }, + { + "name": "php-jsonpointer/php-jsonpointer", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/raphaelstolt/php-jsonpointer.git", + "reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/raphaelstolt/php-jsonpointer/zipball/4428f86c6f23846e9faa5a420c4ef14e485b3afb", + "reference": "4428f86c6f23846e9faa5a420c4ef14e485b3afb", + "shasum": "" + }, + "require": { + "php": ">=5.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^1.11", + "phpunit/phpunit": "4.6.*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-0": { + "Rs\\Json": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], "authors": [ { - "name": "PayPal", - "homepage": "https://github.com/paypal/rest-api-sdk-php/contributors" + "name": "Raphael Stolt", + "email": "raphael.stolt@gmail.com", + "homepage": "http://raphaelstolt.blogspot.com/" } ], - "description": "PayPal's PHP SDK for REST APIs", - "homepage": "http://paypal.github.io/PayPal-PHP-SDK/", + "description": "Implementation of JSON Pointer (http://tools.ietf.org/html/rfc6901)", + "homepage": "https://github.com/raphaelstolt/php-jsonpointer", "keywords": [ - "payments", - "paypal", - "rest", - "sdk" + "json", + "json pointer", + "json traversal" ], "support": { - "issues": "https://github.com/paypal/PayPal-PHP-SDK/issues", - "source": "https://github.com/paypal/PayPal-PHP-SDK/tree/stable" + "issues": "https://github.com/raphaelstolt/php-jsonpointer/issues", + "source": "https://github.com/raphaelstolt/php-jsonpointer/tree/master" }, - "abandoned": "paypal/paypal-server-sdk", - "time": "2016-01-20T17:45:52+00:00" + "time": "2016-08-29T08:51:01+00:00" }, { "name": "phpoption/phpoption", @@ -5677,6 +6132,134 @@ ], "time": "2025-08-25T11:46:57+00:00" }, + { + "name": "spatie/image", + "version": "3.8.6", + "source": { + "type": "git", + "url": "https://github.com/spatie/image.git", + "reference": "0872c5968a7f044fe1e960c26433e54ceaede696" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image/zipball/0872c5968a7f044fe1e960c26433e54ceaede696", + "reference": "0872c5968a7f044fe1e960c26433e54ceaede696", + "shasum": "" + }, + "require": { + "ext-exif": "*", + "ext-json": "*", + "ext-mbstring": "*", + "php": "^8.2", + "spatie/image-optimizer": "^1.7.5", + "spatie/temporary-directory": "^2.2", + "symfony/process": "^6.4|^7.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-imagick": "*", + "laravel/sail": "^1.34", + "pestphp/pest": "^2.28", + "phpstan/phpstan": "^1.10.50", + "spatie/pest-plugin-snapshots": "^2.1", + "spatie/pixelmatch-php": "^1.0", + "spatie/ray": "^1.40.1", + "symfony/var-dumper": "^6.4|7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Image\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Manipulate images with an expressive API", + "homepage": "https://github.com/spatie/image", + "keywords": [ + "image", + "spatie" + ], + "support": { + "source": "https://github.com/spatie/image/tree/3.8.6" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-09-25T12:06:17+00:00" + }, + { + "name": "spatie/image-optimizer", + "version": "1.8.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/image-optimizer.git", + "reference": "4fd22035e81d98fffced65a8c20d9ec4daa9671c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/image-optimizer/zipball/4fd22035e81d98fffced65a8c20d9ec4daa9671c", + "reference": "4fd22035e81d98fffced65a8c20d9ec4daa9671c", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.3|^8.0", + "psr/log": "^1.0 | ^2.0 | ^3.0", + "symfony/process": "^4.2|^5.0|^6.0|^7.0" + }, + "require-dev": { + "pestphp/pest": "^1.21", + "phpunit/phpunit": "^8.5.21|^9.4.4", + "symfony/var-dumper": "^4.2|^5.0|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\ImageOptimizer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily optimize images using PHP", + "homepage": "https://github.com/spatie/image-optimizer", + "keywords": [ + "image-optimizer", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/image-optimizer/issues", + "source": "https://github.com/spatie/image-optimizer/tree/1.8.0" + }, + "time": "2024-11-04T08:24:54+00:00" + }, { "name": "spatie/invade", "version": "2.1.0", @@ -5736,6 +6319,116 @@ ], "time": "2024-05-17T09:06:10+00:00" }, + { + "name": "spatie/laravel-medialibrary", + "version": "11.15.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-medialibrary.git", + "reference": "9d1e9731d36817d1649bc584b2c40c0c9d4bcfac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-medialibrary/zipball/9d1e9731d36817d1649bc584b2c40c0c9d4bcfac", + "reference": "9d1e9731d36817d1649bc584b2c40c0c9d4bcfac", + "shasum": "" + }, + "require": { + "composer/semver": "^3.4", + "ext-exif": "*", + "ext-fileinfo": "*", + "ext-json": "*", + "illuminate/bus": "^10.2|^11.0|^12.0", + "illuminate/conditionable": "^10.2|^11.0|^12.0", + "illuminate/console": "^10.2|^11.0|^12.0", + "illuminate/database": "^10.2|^11.0|^12.0", + "illuminate/pipeline": "^10.2|^11.0|^12.0", + "illuminate/support": "^10.2|^11.0|^12.0", + "maennchen/zipstream-php": "^3.1", + "php": "^8.2", + "spatie/image": "^3.3.2", + "spatie/laravel-package-tools": "^1.16.1", + "spatie/temporary-directory": "^2.2", + "symfony/console": "^6.4.1|^7.0" + }, + "conflict": { + "php-ffmpeg/php-ffmpeg": "<0.6.1" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.293.10", + "ext-imagick": "*", + "ext-pdo_sqlite": "*", + "ext-zip": "*", + "guzzlehttp/guzzle": "^7.8.1", + "larastan/larastan": "^2.7|^3.0", + "league/flysystem-aws-s3-v3": "^3.22", + "mockery/mockery": "^1.6.7", + "orchestra/testbench": "^7.0|^8.17|^9.0|^10.0", + "pestphp/pest": "^2.28|^3.5", + "phpstan/extension-installer": "^1.3.1", + "spatie/laravel-ray": "^1.33", + "spatie/pdf-to-image": "^2.2|^3.0", + "spatie/pest-expectations": "^1.13", + "spatie/pest-plugin-snapshots": "^2.1" + }, + "suggest": { + "league/flysystem-aws-s3-v3": "Required to use AWS S3 file storage", + "php-ffmpeg/php-ffmpeg": "Required for generating video thumbnails", + "spatie/pdf-to-image": "Required for generating thumbnails of PDFs and SVGs" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\MediaLibrary\\MediaLibraryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Spatie\\MediaLibrary\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Associate files with Eloquent models", + "homepage": "https://github.com/spatie/laravel-medialibrary", + "keywords": [ + "cms", + "conversion", + "downloads", + "images", + "laravel", + "laravel-medialibrary", + "media", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-medialibrary/issues", + "source": "https://github.com/spatie/laravel-medialibrary/tree/11.15.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-09-19T06:51:45+00:00" + }, { "name": "spatie/laravel-package-tools", "version": "1.92.7", @@ -5797,6 +6490,89 @@ ], "time": "2025-07-17T15:46:43+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "6.21.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/6a118e8855dfffcd90403aab77bbf35a03db51b3", + "reference": "6a118e8855dfffcd90403aab77bbf35a03db51b3", + "shasum": "" + }, + "require": { + "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0", + "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0", + "php": "^8.0" + }, + "require-dev": { + "laravel/passport": "^11.0|^12.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.4|^10.1|^11.5" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "6.x-dev", + "dev-master": "6.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 8.0 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/6.21.0" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-07-23T16:08:05+00:00" + }, { "name": "spatie/laravel-tags", "version": "4.10.0", @@ -6015,6 +6791,67 @@ ], "time": "2025-02-21T14:16:57+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.3.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "reference": "580eddfe9a0a41a902cac6eeb8f066b42e65a32b", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.3.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2025-01-13T13:04:43+00:00" + }, { "name": "stephenjude/filament-blog", "version": "4.2.1", diff --git a/config/app.php b/config/app.php index 76a1b7f..21061de 100644 --- a/config/app.php +++ b/config/app.php @@ -13,7 +13,7 @@ return [ | */ - 'name' => env('APP_NAME', 'Laravel'), + 'name' => env('APP_NAME', 'Fotospiel.App'), /* |-------------------------------------------------------------------------- @@ -65,7 +65,7 @@ return [ | */ - 'timezone' => 'UTC', + 'timezone' => 'Europe/Berlin', /* |-------------------------------------------------------------------------- @@ -123,4 +123,6 @@ return [ 'store' => env('APP_MAINTENANCE_STORE', 'database'), ], + 'require_registration' => env('REQUIRE_REGISTRATION', true), + ]; diff --git a/config/filament-blog.php b/config/filament-blog.php index 91bb423..6f8ddd7 100644 --- a/config/filament-blog.php +++ b/config/filament-blog.php @@ -67,8 +67,8 @@ return [ 'h1', 'h2', 'h3', - 'hr', - 'image', + //'hr', + //'image', 'italic', 'link', 'orderedList', diff --git a/config/filament.php b/config/filament.php new file mode 100644 index 0000000..0092bca --- /dev/null +++ b/config/filament.php @@ -0,0 +1,120 @@ + [ + + // 'echo' => [ + // 'broadcaster' => 'pusher', + // 'key' => env('VITE_PUSHER_APP_KEY'), + // 'cluster' => env('VITE_PUSHER_APP_CLUSTER'), + // 'wsHost' => env('VITE_PUSHER_HOST'), + // 'wsPort' => env('VITE_PUSHER_PORT'), + // 'wssPort' => env('VITE_PUSHER_PORT'), + // 'authEndpoint' => '/broadcasting/auth', + // 'disableStats' => true, + // 'encrypted' => true, + // 'forceTLS' => true, + // ], + + ], + + /* + |-------------------------------------------------------------------------- + | Default Filesystem Disk + |-------------------------------------------------------------------------- + | + | This is the storage disk Filament will use to store files. You may use + | any of the disks defined in the `config/filesystems.php`. + | + */ + + 'default_filesystem_disk' => env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Assets Path + |-------------------------------------------------------------------------- + | + | This is the directory where Filament's assets will be published to. It + | is relative to the `public` directory of your Laravel application. + | + | After changing the path, you should run `php artisan filament:assets`. + | + */ + + 'assets_path' => null, + + /* + |-------------------------------------------------------------------------- + | Cache Path + |-------------------------------------------------------------------------- + | + | This is the directory that Filament will use to store cache files that + | are used to optimize the registration of components. + | + | After changing the path, you should run `php artisan filament:cache-components`. + | + */ + + 'cache_path' => base_path('bootstrap/cache/filament'), + + /* + |-------------------------------------------------------------------------- + | Livewire Loading Delay + |-------------------------------------------------------------------------- + | + | This sets the delay before loading indicators appear. + | + | Setting this to 'none' makes indicators appear immediately, which can be + | desirable for high-latency connections. Setting it to 'default' applies + | Livewire's standard 200ms delay. + | + */ + + 'livewire_loading_delay' => 'default', + + /* + |-------------------------------------------------------------------------- + | File Generation + |-------------------------------------------------------------------------- + | + | Artisan commands that generate files can be configured here by setting + | configuration flags that will impact their location or content. + | + | Often, this is useful to preserve file generation behavior from a + | previous version of Filament, to ensure consistency between older and + | newer generated files. These flags are often documented in the upgrade + | guide for the version of Filament you are upgrading to. + | + */ + + 'file_generation' => [ + 'flags' => [], + ], + + /* + |-------------------------------------------------------------------------- + | System Route Prefix + |-------------------------------------------------------------------------- + | + | This is the prefix used for the system routes that Filament registers, + | such as the routes for downloading exports and failed import rows. + | + */ + + 'system_route_prefix' => 'filament', + +]; diff --git a/database/migrations/2025_09_01_100200_create_events_table.php b/database/migrations/2025_09_01_100200_create_events_table.php index 8c462bc..eabc6db 100644 --- a/database/migrations/2025_09_01_100200_create_events_table.php +++ b/database/migrations/2025_09_01_100200_create_events_table.php @@ -10,8 +10,8 @@ return new class extends Migration { Schema::create('events', function (Blueprint $table) { $table->id(); $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); - $table->string('name'); - $table->text('description')->nullable(); + $table->json('name'); + $table->json('description')->nullable(); $table->dateTime('date'); $table->string('slug')->unique(); $table->string('location')->nullable(); diff --git a/database/migrations/2025_09_01_100300_create_tasks_table.php b/database/migrations/2025_09_01_100300_create_tasks_table.php index b3ee94a..f3d810d 100644 --- a/database/migrations/2025_09_01_100300_create_tasks_table.php +++ b/database/migrations/2025_09_01_100300_create_tasks_table.php @@ -12,9 +12,9 @@ return new class extends Migration { $table->foreignId('tenant_id')->constrained()->onDelete('cascade'); $table->unsignedBigInteger('emotion_id')->nullable(); $table->unsignedBigInteger('event_type_id')->nullable(); - $table->string('title'); - $table->text('description')->nullable(); - $table->text('example_text')->nullable(); + $table->json('title'); + $table->json('description')->nullable(); + $table->json('example_text')->nullable(); $table->dateTime('due_date')->nullable(); $table->boolean('is_completed')->default(false); $table->enum('priority', ['low', 'medium', 'high', 'urgent'])->default('medium'); diff --git a/database/migrations/2025_09_26_191137_migrate_credits_to_packages.php b/database/migrations/2025_09_26_191103_migrate_credits_to_packages.php similarity index 97% rename from database/migrations/2025_09_26_191137_migrate_credits_to_packages.php rename to database/migrations/2025_09_26_191103_migrate_credits_to_packages.php index 5d888d6..4ba4e4f 100644 --- a/database/migrations/2025_09_26_191137_migrate_credits_to_packages.php +++ b/database/migrations/2025_09_26_191103_migrate_credits_to_packages.php @@ -72,7 +72,7 @@ return new class extends Migration } // Migrate tenant credits to tenant_packages (Free package) - DB::table('tenants')->where('event_credits_balance', '>', 0)->chunk(100, function ($tenants) { + DB::table('tenants')->where('event_credits_balance', '>', 0)->orderBy('id')->chunk(100, function ($tenants) { foreach ($tenants as $tenant) { $freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id; DB::table('tenant_packages')->insert([ @@ -106,7 +106,7 @@ return new class extends Migration }); // Migrate event purchases to event_packages (if any existing events) - DB::table('events')->chunk(100, function ($events) { + DB::table('events')->orderBy('id')->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; 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 index 09aeb35..7381fcd 100644 --- 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 @@ -15,15 +15,37 @@ return new class extends Migration Schema::dropIfExists('purchase_history'); Schema::dropIfExists('event_purchases'); - Schema::table('tenants', function (Blueprint $table) { - $table->dropColumn([ - 'event_credits_balance', - 'subscription_tier', - 'subscription_expires_at', - 'free_event_granted_at', - 'total_revenue' - ]); - }); + if (Schema::hasTable('package_purchases')) { + Schema::table('package_purchases', function (Blueprint $table) { + $table->dropIndex(['tenant_id', 'purchased_at']); + }); + } + + if (Schema::hasColumn('tenants', 'event_credits_balance')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('event_credits_balance'); + }); + } + if (Schema::hasColumn('tenants', 'subscription_tier')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('subscription_tier'); + }); + } + if (Schema::hasColumn('tenants', 'subscription_expires_at')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('subscription_expires_at'); + }); + } + if (Schema::hasColumn('tenants', 'free_event_granted_at')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('free_event_granted_at'); + }); + } + if (Schema::hasColumn('tenants', 'total_revenue')) { + Schema::table('tenants', function (Blueprint $table) { + $table->dropColumn('total_revenue'); + }); + } } /** diff --git a/database/migrations/2025_09_27_110000_add_personal_fields_to_users_table.php b/database/migrations/2025_09_27_110000_add_personal_fields_to_users_table.php new file mode 100644 index 0000000..05ce3b4 --- /dev/null +++ b/database/migrations/2025_09_27_110000_add_personal_fields_to_users_table.php @@ -0,0 +1,31 @@ +string('first_name')->nullable()->after('name'); + $table->string('last_name')->nullable()->after('first_name'); + $table->text('address')->nullable()->after('last_name'); + $table->string('phone')->nullable()->after('address'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['first_name', 'last_name', 'address', 'phone']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_27_110100_add_user_id_to_tenants_table.php b/database/migrations/2025_09_27_110100_add_user_id_to_tenants_table.php new file mode 100644 index 0000000..cf0811e --- /dev/null +++ b/database/migrations/2025_09_27_110100_add_user_id_to_tenants_table.php @@ -0,0 +1,29 @@ +foreignId('user_id')->nullable()->constrained('users')->onDelete('cascade')->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('tenants', function (Blueprint $table) { + $table->dropForeign(['user_id']); + $table->dropColumn('user_id'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_27_110200_make_tenant_id_required_in_package_purchases_table.php b/database/migrations/2025_09_27_110200_make_tenant_id_required_in_package_purchases_table.php new file mode 100644 index 0000000..f9c9942 --- /dev/null +++ b/database/migrations/2025_09_27_110200_make_tenant_id_required_in_package_purchases_table.php @@ -0,0 +1,30 @@ +dropForeign(['tenant_id']); + $table->foreignId('tenant_id')->constrained()->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('package_purchases', function (Blueprint $table) { + $table->dropForeign(['tenant_id']); + $table->foreignId('tenant_id')->nullable()->constrained()->change(); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_09_27_110300_add_unique_index_to_username_in_users_table.php b/database/migrations/2025_09_27_110300_add_unique_index_to_username_in_users_table.php new file mode 100644 index 0000000..c021ed0 --- /dev/null +++ b/database/migrations/2025_09_27_110300_add_unique_index_to_username_in_users_table.php @@ -0,0 +1,28 @@ +unique('username'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropUnique(['username']); + }); + } +}; \ No newline at end of file diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index d8813f2..517b5d0 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -1,4 +1,4 @@ -call([ LegalPagesSeeder::class, + PackageSeeder::class, ]); // Seed core demo data for frontend previews diff --git a/database/seeders/DemoAchievementsSeeder.php b/database/seeders/DemoAchievementsSeeder.php index 914e4bf..72b395f 100644 --- a/database/seeders/DemoAchievementsSeeder.php +++ b/database/seeders/DemoAchievementsSeeder.php @@ -1,4 +1,4 @@ -first(); if(!$type){ return; } + $demoTenant = \App\Models\Tenant::where('slug', 'demo')->first(); + if (!$demoTenant) { return; } Event::updateOrCreate(['slug'=>'demo-wedding-2025'], [ + 'tenant_id' => $demoTenant->id, 'name' => ['de'=>'Demo Hochzeit 2025','en'=>'Demo Wedding 2025'], 'description' => ['de'=>'Demo-Event','en'=>'Demo event'], 'date' => now()->addMonths(3)->toDateString(), diff --git a/database/seeders/PackageSeeder.php b/database/seeders/PackageSeeder.php index 4091ec1..f567c87 100644 --- a/database/seeders/PackageSeeder.php +++ b/database/seeders/PackageSeeder.php @@ -4,6 +4,7 @@ namespace Database\Seeders; use Illuminate\Database\Seeder; use App\Models\Package; +use App\Enums\PackageType; class PackageSeeder extends Seeder { @@ -15,124 +16,98 @@ class PackageSeeder extends Seeder // Endcustomer Packages Package::create([ 'name' => 'Free / Test', - 'type' => 'endcustomer', + 'type' => PackageType::ENDCUSTOMER, 'price' => 0.00, 'max_photos' => 30, - 'max_guests' => 10, - 'gallery_days' => 3, + 'max_guests' => 50, + 'gallery_days' => 7, 'max_tasks' => 5, - 'watermark_allowed' => false, + 'watermark_allowed' => true, '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, + 'type' => PackageType::ENDCUSTOMER, + 'price' => 29.00, + 'max_photos' => 200, + 'max_guests' => 100, + 'gallery_days' => 30, + 'max_tasks' => 10, 'watermark_allowed' => true, 'branding_allowed' => false, 'features' => json_encode([ - 'extended_gallery' => true, - 'guest_sharing' => true, - 'basic_analytics' => true, + 'basic_uploads' => true, + 'unlimited_sharing' => true, + 'no_watermark' => true, + 'custom_tasks' => true, ]), - 'description' => 'Perfekt für kleine Events wie Geburtstage oder Firmenfeiern.', ]); Package::create([ 'name' => 'Pro', - 'type' => 'endcustomer', - 'price' => 49.00, + 'type' => PackageType::ENDCUSTOMER, + 'price' => 79.00, 'max_photos' => 1000, - 'max_guests' => 200, - 'gallery_days' => 30, - 'max_tasks' => 50, + 'max_guests' => 500, + 'gallery_days' => 90, + 'max_tasks' => 20, + 'watermark_allowed' => false, + 'branding_allowed' => false, + 'features' => json_encode([ + 'basic_uploads' => true, + 'unlimited_sharing' => true, + 'no_watermark' => true, + 'custom_tasks' => true, + 'advanced_analytics' => true, + 'priority_support' => true, + ]), + ]); + + // Reseller Packages + Package::create([ + 'name' => 'S (Small Reseller)', + 'type' => PackageType::RESELLER, + 'price' => 199.00, + 'max_photos' => 500, // per event limit + 'max_guests' => null, // unlimited + 'gallery_days' => null, + 'max_tasks' => null, // unlimited 'watermark_allowed' => true, 'branding_allowed' => true, + 'max_events_per_year' => 5, + 'expires_after' => now()->addYear(), 'features' => json_encode([ - 'unlimited_sharing' => true, - 'advanced_analytics' => true, + 'reseller_dashboard' => 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, + 'name' => 'M (Medium Reseller)', + 'type' => PackageType::RESELLER, + 'price' => 399.00, + 'max_photos' => 1000, // per event limit + 'max_guests' => null, // unlimited 'gallery_days' => null, - 'max_tasks' => null, + 'max_tasks' => null, // unlimited '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, + 'custom_branding' => true, + 'priority_support' => 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.', - ]); } -} +} \ No newline at end of file diff --git a/resources/lang/de/auth.php b/resources/lang/de/auth.php new file mode 100644 index 0000000..709b9fb --- /dev/null +++ b/resources/lang/de/auth.php @@ -0,0 +1,35 @@ + 'Diese Anmeldedaten wurden nicht gefunden.', + 'password' => 'Das Passwort ist falsch.', + 'throttle' => 'Zu viele Login-Versuche. Bitte versuche es in :seconds Sekunden erneut.', + + 'login' => [ + 'title' => 'Anmelden', + 'username_or_email' => 'Username oder E-Mail', + 'password' => 'Passwort', + 'remember' => 'Angemeldet bleiben', + 'submit' => 'Anmelden', + ], + + 'register' => [ + 'title' => 'Registrieren', + 'name' => 'Vollständiger Name', + 'username' => 'Username', + 'email' => 'E-Mail-Adresse', + 'password' => 'Passwort', + 'password_confirmation' => 'Passwort bestätigen', + 'first_name' => 'Vorname', + 'last_name' => 'Nachname', + 'address' => 'Adresse', + 'phone' => 'Telefonnummer', + 'privacy_consent' => 'Ich stimme der Datenschutzerklärung zu und akzeptiere die Verarbeitung meiner persönlichen Daten.', + 'submit' => 'Registrieren', + ], + + 'verification' => [ + 'notice' => 'Bitte bestätigen Sie Ihre E-Mail-Adresse.', + 'resend' => 'E-Mail erneut senden', + ], +]; \ No newline at end of file diff --git a/resources/lang/de/profile.php b/resources/lang/de/profile.php new file mode 100644 index 0000000..3ac6eb9 --- /dev/null +++ b/resources/lang/de/profile.php @@ -0,0 +1,17 @@ + 'Profil bearbeiten', + 'first_name' => 'Vorname', + 'first_name_placeholder' => 'Ihr Vorname', + 'last_name' => 'Nachname', + 'last_name_placeholder' => 'Ihr Nachname', + 'address' => 'Adresse', + 'phone' => 'Telefonnummer', + 'phone_placeholder' => 'Ihre Telefonnummer', + 'password' => 'Passwort', + 'password_placeholder' => 'Neues Passwort (optional)', + 'save' => 'Speichern', + 'delete_account' => 'Account löschen', + 'delete' => 'Löschen', +]; \ No newline at end of file diff --git a/resources/views/emails/purchase.blade.php b/resources/views/emails/purchase.blade.php new file mode 100644 index 0000000..b749b5b --- /dev/null +++ b/resources/views/emails/purchase.blade.php @@ -0,0 +1,14 @@ + + + + Purchase Confirmation + + +

Kauf-Bestätigung

+

Vielen Dank für Ihren Kauf, {{ $purchase->user->fullName }}!

+

Package: {{ $purchase->package->name }}

+

Preis: {{ $purchase->amount }} €

+

Das Package ist nun in Ihrem Tenant-Account aktiviert.

+

Mit freundlichen Grüßen,
Das Fotospiel-Team

+ + \ No newline at end of file diff --git a/resources/views/emails/welcome.blade.php b/resources/views/emails/welcome.blade.php new file mode 100644 index 0000000..4cbb78a --- /dev/null +++ b/resources/views/emails/welcome.blade.php @@ -0,0 +1,14 @@ + + + + Welcome to Fotospiel + + +

Willkommen bei Fotospiel, {{ $user->fullName }}!

+

Vielen Dank für Ihre Registrierung. Ihr Account ist nun aktiv.

+

Username: {{ $user->username }}

+

E-Mail: {{ $user->email }}

+

Bitte verifizieren Sie Ihre E-Mail-Adresse, um auf das Admin-Panel zuzugreifen.

+

Mit freundlichen Grüßen,
Das Fotospiel-Team

+ + \ No newline at end of file diff --git a/resources/views/layouts/marketing.blade.php b/resources/views/layouts/marketing.blade.php new file mode 100644 index 0000000..b5a2363 --- /dev/null +++ b/resources/views/layouts/marketing.blade.php @@ -0,0 +1,73 @@ + + + + + + @yield('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes') + + + @vite(['resources/css/app.css']) + + + + +
+
+
+ Fotospiel + + + + +
+ + + +
+
+ +
+ @yield('content') +
+ + + + + @stack('scripts') + + \ No newline at end of file diff --git a/resources/views/legal/datenschutz.blade.php b/resources/views/legal/datenschutz.blade.php index 8ba4582..3f191a4 100644 --- a/resources/views/legal/datenschutz.blade.php +++ b/resources/views/legal/datenschutz.blade.php @@ -15,5 +15,14 @@

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.

+ +

Persönliche Datenverarbeitung

+

Bei der Registrierung und Nutzung des Systems werden folgende persönliche Daten verarbeitet: Vor- und Nachname, Adresse, Telefonnummer, E-Mail-Adresse, Username. Diese Daten werden zur Erfüllung des Vertrags (Package-Kauf, Tenant-Management) und für die Authentifizierung verwendet. Die Verarbeitung erfolgt gemäß Art. 6 Abs. 1 lit. b DSGVO.

+ +

Account-Löschung

+

Sie haben das Recht, Ihre persönlichen Daten jederzeit löschen zu lassen (Recht auf Löschung, Art. 17 DSGVO). Kontaktieren Sie uns unter [E-Mail] zur Löschung Ihres Accounts. Alle zugehörigen Daten (Events, Photos, Purchases) werden gelöscht, soweit keine gesetzlichen Aufbewahrungspflichten bestehen.

+ +

Datensicherheit

+

Wir verwenden HTTPS, verschlüsselte Speicherung (Passwörter hashed) und regelmäßige Backups. Zugriff auf Daten ist rollebasierend beschränkt (Tenant vs SuperAdmin).

\ No newline at end of file diff --git a/resources/views/legal/kontakt.blade.php b/resources/views/legal/kontakt.blade.php new file mode 100644 index 0000000..578c13e --- /dev/null +++ b/resources/views/legal/kontakt.blade.php @@ -0,0 +1,40 @@ +@extends('layouts.marketing') + +@section('title', 'Kontakt - Fotospiel') + +@section('content') +
+
+

Kontakt

+

Haben Sie Fragen? Schreiben Sie uns!

+
+ @csrf +
+ + +
+
+ + +
+
+ + +
+ +
+ @if (session('success')) +

{{ session('success') }}

+ @endif + @if ($errors->any()) +
+
    + @foreach ($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif +
+
+@endsection \ No newline at end of file diff --git a/resources/views/marketing.blade.php b/resources/views/marketing.blade.php index e9a7128..64de641 100644 --- a/resources/views/marketing.blade.php +++ b/resources/views/marketing.blade.php @@ -1,57 +1,8 @@ - - - - - - Fotospiel - Event-Fotos einfach und sicher mit QR-Codes - - - @vite(['resources/css/app.css']) - - - - -
-
-
- Fotospiel - - - - -
- - - -
-
+@extends('layouts.marketing') +@section('title', 'Fotospiel - Event-Fotos einfach und sicher mit QR-Codes') + +@section('content')
@@ -142,7 +93,7 @@

Kontakt

-
+ @csrf
@@ -210,17 +161,4 @@
- - - - - \ No newline at end of file +@endsection \ No newline at end of file diff --git a/resources/views/marketing/blog-show.blade.php b/resources/views/marketing/blog-show.blade.php index 5934c47..318f5a8 100644 --- a/resources/views/marketing/blog-show.blade.php +++ b/resources/views/marketing/blog-show.blade.php @@ -3,12 +3,8 @@ - {{ $post->meta_title ?? $post->title }} - Fotospiel - - - - - + {{ $post->title }} - Fotospiel Blog + @vite(['resources/css/app.css']) @@ -33,44 +29,35 @@
Blog - Pricing + Pricing Contact - Jetzt starten + Jetzt starten - -
-
- @if ($post->featured_image) - {{ $post->title }} - @endif + +
+

{{ $post->title }}

-

{{ $post->excerpt }}

-

Veröffentlicht am {{ $post->published_at->format('d.m.Y') }}

+

Von {{ $post->author->name ?? 'Fotospiel Team' }} | {{ $post->published_at->format('d.m.Y') }}

+ @if ($post->featured_image) + {{ $post->title }} + @endif
- -
-
-
- {!! $post->content !!} -
-
-

Kategorien:

- @foreach ($post->categories as $category) - {{ $category->name }} - @endforeach -

Tags:

- @foreach ($post->tags as $tag) - #{{ $tag->name }} - @endforeach -
- + +
+
+ {!! $post->content !!} +
+
+ + +
+
diff --git a/resources/views/marketing/blog.blade.php b/resources/views/marketing/blog.blade.php index 340b977..c2d5cbd 100644 --- a/resources/views/marketing/blog.blade.php +++ b/resources/views/marketing/blog.blade.php @@ -1,41 +1,8 @@ - - - - - - Fotospiel - Blog - - - @vite(['resources/css/app.css']) - - - -
- -
+@extends('layouts.marketing') +@section('title', 'Fotospiel - Blog') + +@section('content')
@@ -73,17 +40,4 @@ @endif
- - - - - \ No newline at end of file +@endsection \ No newline at end of file diff --git a/resources/views/marketing/occasions.blade.php b/resources/views/marketing/occasions.blade.php index 970fa88..e83ed68 100644 --- a/resources/views/marketing/occasions.blade.php +++ b/resources/views/marketing/occasions.blade.php @@ -29,7 +29,7 @@
Blog - Pricing + Pricing Contact Packages wählen diff --git a/resources/views/marketing/packages.blade.php b/resources/views/marketing/packages.blade.php index 4dac58a..e128729 100644 --- a/resources/views/marketing/packages.blade.php +++ b/resources/views/marketing/packages.blade.php @@ -73,7 +73,11 @@ @endif @if($package->features) - @foreach(json_decode($package->features, true) as $feature => $enabled) + @php + $features = is_array($package->features) ? $package->features : (is_string($package->features) ? json_decode($package->features, true) : []); + $features = is_array($features) ? $features : []; + @endphp + @foreach($features as $feature => $enabled) @if($enabled)
  • @@ -85,8 +89,8 @@ @endforeach @endif - - {{ __('marketing.packages.buy_now') }} + + {{ __('marketing.packages.register_buy') }} @endforeach @@ -118,7 +122,11 @@
  • @endif @if($package->features) - @foreach(json_decode($package->features, true) as $feature => $enabled) + @php + $features = is_array($package->features) ? $package->features : (is_string($package->features) ? json_decode($package->features, true) : []); + $features = is_array($features) ? $features : []; + @endphp + @foreach($features as $feature => $enabled) @if($enabled)
  • @@ -130,8 +138,8 @@ @endforeach @endif - - {{ __('marketing.packages.subscribe_now') }} + + {{ __('marketing.packages.register_subscribe') }} @endforeach diff --git a/resources/views/marketing/profile.blade.php b/resources/views/marketing/profile.blade.php new file mode 100644 index 0000000..72fe318 --- /dev/null +++ b/resources/views/marketing/profile.blade.php @@ -0,0 +1,99 @@ +@extends('layouts.marketing') + +@section('title', __('profile.title')) + +@section('content') +
    +
    +
    +

    + {{ __('profile.title') }} +

    +
    + + @csrf + @method('PATCH') + + +
    + + + @error('first_name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('last_name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('address') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('phone') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('password') +

    {{ $message }}

    + @enderror +
    + +
    + +
    + +
    +

    + {{ __('profile.delete_account') }} {{ __('profile.delete') }} +

    +
    + +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/marketing/register.blade.php b/resources/views/marketing/register.blade.php new file mode 100644 index 0000000..22aa70b --- /dev/null +++ b/resources/views/marketing/register.blade.php @@ -0,0 +1,182 @@ +@extends('layouts.marketing') + +@section('title', __('auth.register')) + +@section('content') +
    +
    +
    +

    + {{ __('auth.register') }} +

    + @if($package ?? false) +
    +

    {{ $package->name }}

    +

    {{ $package->description }}

    +

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

    +
    + @endif +
    +
    + @csrf + @if($package ?? false) + + @endif + + +
    + + + @error('name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('username') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('email') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('password') +

    {{ $message }}

    + @enderror +
    + + +
    + + +
    + + +
    + + + @error('first_name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('last_name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('address') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('phone') +

    {{ $message }}

    + @enderror +
    + + +
    +
    + +
    +
    + + @error('privacy_consent') +

    {{ $message }}

    + @enderror +
    +
    + +
    + +
    + +
    +

    + {{ __('auth.have_account') }} + + {{ __('auth.login') }} + +

    +
    +
    +
    +
    +@endsection \ No newline at end of file diff --git a/resources/views/marketing/success.blade.php b/resources/views/marketing/success.blade.php index bc31f28..52d96f0 100644 --- a/resources/views/marketing/success.blade.php +++ b/resources/views/marketing/success.blade.php @@ -1,13 +1,58 @@ - - - - - - Zahlung erfolgreich - Fotospiel - - -

    Zahlung erfolgreich!

    -

    Vielen Dank für Ihren Kauf. Ihr Konto wurde aktualisiert.

    - Zum Admin-Dashboard - - \ No newline at end of file +@extends('marketing.layout') + +@section('title', __('marketing.success.title')) + +@section('content') +
    + @auth + @if(auth()->user()->email_verified_at) + +
    +
    + Loading... +
    +

    {{ __('marketing.success.redirecting') }}

    +
    + @else +
    +
    +

    + {{ __('marketing.success.verify_email') }} +

    +

    + {{ __('marketing.success.check_email') }} +

    +
    + @csrf + +
    +

    + {{ __('auth.have_account') }} {{ __('auth.login') }} +

    +
    +
    + @endif + @else +
    +
    +

    + {{ __('marketing.success.complete_purchase') }} +

    +

    + {{ __('marketing.success.login_to_continue') }} +

    + + {{ __('auth.login') }} + +

    + {{ __('auth.no_account') }} {{ __('auth.register') }} +

    +
    +
    + @endauth +
    +@endsection \ No newline at end of file diff --git a/resources/views/profile/edit.blade.php b/resources/views/profile/edit.blade.php new file mode 100644 index 0000000..2837a44 --- /dev/null +++ b/resources/views/profile/edit.blade.php @@ -0,0 +1,192 @@ +@extends('layouts.marketing') + +@section('title', __('profile.edit_title')) + +@section('content') +
    +
    +
    +
    +

    + {{ __('profile.personal_information') }} +

    +

    + {{ __('profile.update_info') }} +

    +
    +
    +
    + @csrf + @method('PATCH') + + +
    +
    + + + @error('name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('username') +

    {{ $message }}

    + @enderror +
    +
    + +
    + +
    + + + @error('email') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('first_name') +

    {{ $message }}

    + @enderror +
    +
    + +
    + +
    + + + @error('last_name') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('phone') +

    {{ $message }}

    + @enderror +
    +
    + + +
    + + + @error('address') +

    {{ $message }}

    + @enderror +
    + + @if (session('status') == 'profile-updated') +
    +

    {{ __('profile.updated_success') }}

    +
    + @endif + +
    + +
    +
    +
    +
    + + +
    +
    +

    + {{ __('profile.password') }} +

    +

    + {{ __('profile.update_password') }} +

    +
    +
    +
    + @csrf + @method('PATCH') + + +
    + + + @error('current_password') +

    {{ $message }}

    + @enderror +
    + + +
    + + + @error('password') +

    {{ $message }}

    + @enderror +
    + + +
    + + +
    + +
    + +
    +
    +
    +
    +
    +
    +@endsection \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index fb4b2b0..e7472af 100644 --- a/routes/api.php +++ b/routes/api.php @@ -9,6 +9,8 @@ use App\Http\Controllers\OAuthController; use App\Http\Controllers\RevenueCatWebhookController; use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\TenantPackageController; +use App\Http\Controllers\StripeController; +use App\Http\Controllers\StripeWebhookController; use Illuminate\Support\Facades\Route; Route::prefix('v1')->name('api.v1.')->group(function () { @@ -40,7 +42,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->only(['index', 'show', 'destroy']) ->parameters(['events' => 'event:slug']); - Route::middleware('credit.check')->group(function () { + Route::middleware('package.check')->group(function () { Route::post('events', [EventController::class, 'store'])->name('tenant.events.store'); Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); }); diff --git a/routes/web.php b/routes/web.php index 79b997e..0c6fa22 100644 --- a/routes/web.php +++ b/routes/web.php @@ -6,14 +6,32 @@ 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'); - Route::get('/blog', [\App\Http\Controllers\MarketingController::class, 'blogIndex'])->name('blog'); - Route::get('/blog/{post}', [\App\Http\Controllers\MarketingController::class, 'blogShow'])->name('blog.show'); + Route::get('/register/{package_id?}', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'create'])->name('register'); + Route::post('/register', [\App\Http\Controllers\Auth\MarketingRegisterController::class, 'store']); }); +// Packages Route (outside locale group for direct access) +Route::view('/packages', 'marketing.packages')->name('packages'); + +// Blog Routes (outside locale group for direct access) +Route::get('/blog', [\App\Http\Controllers\MarketingController::class, 'blogIndex'])->name('blog'); +Route::get('/blog/{post}', [\App\Http\Controllers\MarketingController::class, 'blogShow'])->name('blog.show'); + + // Legal Pages + Route::get('/impressum', function () { + return view('legal.impressum'); + })->name('impressum'); + Route::get('/datenschutz', function () { + return view('legal.datenschutz'); + })->name('datenschutz'); + Route::get('/kontakt', function () { + return view('legal.kontakt'); + })->name('kontakt'); + Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit'); + Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', function () { return Inertia::render('dashboard'); @@ -30,17 +48,6 @@ Route::view('/pwa/{any?}', 'guest')->where('any', '.*'); // Minimal public API for Guest PWA (stateless; no CSRF) Route::prefix('api/v1')->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])->group(function () { - // Public legal pages (for marketing) - Route::get('/impressum', function () { - return view('legal.impressum'); - })->name('impressum'); - Route::get('/datenschutz', function () { - return view('legal.datenschutz'); - })->name('datenschutz'); - Route::get('/kontakt', function () { - return view('legal.kontakt'); - })->name('kontakt'); - Route::post('/kontakt', [\App\Http\Controllers\MarketingController::class, 'contact'])->name('kontakt.submit'); }); // Stripe webhooks (no CSRF, no auth) @@ -66,8 +73,8 @@ Route::get('/super-admin/templates/emotions.csv', function () { }); // Tenant Admin PWA shell -Route::view('/admin/{any?}', 'admin')->where('any', '.*'); -Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png']); +Route::view('/admin/{any?}', 'admin')->middleware(['auth', 'verified', 'tenant'])->where('any', '.*'); +Route::get('/admin/qr', [\App\Http\Controllers\Admin\QrController::class, 'png'])->middleware(['auth', 'verified', 'tenant']); Route::get('/super-admin/templates/tasks.csv', function () { $headers = [ 'Content-Type' => 'text/csv', @@ -82,13 +89,13 @@ Route::get('/super-admin/templates/tasks.csv', function () { return response()->stream($callback, 200, $headers); }); -// E-Commerce Routen für Marketing -Route::get('/buy-credits/{package}', [\App\Http\Controllers\MarketingController::class, 'checkout'])->name('buy.credits'); -Route::get('/checkout/{sessionId}', [\App\Http\Controllers\MarketingController::class, 'stripeCheckout']); -Route::get('/paypal-checkout/{package}', [\App\Http\Controllers\MarketingController::class, 'paypalCheckout']); -Route::get('/marketing/success/{package}', [\App\Http\Controllers\MarketingController::class, 'success'])->name('marketing.success'); +Route::middleware('auth')->group(function () { + Route::get('/buy-packages/{package_id}', [\App\Http\Controllers\MarketingController::class, 'buyPackages'])->name('buy.packages'); + Route::get('/profile', [\App\Http\Controllers\ProfileController::class, 'edit'])->name('profile.edit'); + Route::patch('/profile', [\App\Http\Controllers\ProfileController::class, 'update'])->name('profile.update'); +}); -// E-Commerce Routen für Marketing -Route::get('/buy-credits/{package}', [\App\Http\Controllers\MarketingController::class, 'checkout'])->name('buy.credits'); -Route::get('/checkout/{sessionId}', [\App\Http\Controllers\MarketingController::class, 'stripeCheckout']); -Route::get('/paypal-checkout/{package}', [\App\Http\Controllers\MarketingController::class, 'paypalCheckout']); +// Success view route (no controller needed, direct view) +Route::get('/marketing/success/{package_id?}', function ($packageId = null) { + return view('marketing.success', compact('packageId')); +})->name('marketing.success'); diff --git a/tests/Feature/PurchaseTest.php b/tests/Feature/PurchaseTest.php new file mode 100644 index 0000000..427bb6c --- /dev/null +++ b/tests/Feature/PurchaseTest.php @@ -0,0 +1,74 @@ +create(['price' => 10]); + + $response = $this->get(route('buy.packages', $package->id)); + + $response->assertRedirect(route('register', ['package_id' => $package->id])); + } + + public function test_unverified_buy_redirects_to_verification() + { + $package = Package::factory()->create(['price' => 10]); + $user = User::factory()->create(['email_verified_at' => null]); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + Auth::login($user); + + $response = $this->get(route('buy.packages', $package->id)); + + $response->assertRedirect(route('verification.notice')); + } + + public function test_free_package_assigns_after_auth() + { + $freePackage = Package::factory()->create(['price' => 0]); + $user = User::factory()->create(['email_verified_at' => now()]); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + Auth::login($user); + + $response = $this->get(route('buy.packages', $freePackage->id)); + + $response->assertRedirect('/admin'); + + $this->assertDatabaseHas('tenant_packages', [ + 'tenant_id' => $tenant->id, + 'package_id' => $freePackage->id, + ]); + + $this->assertDatabaseHas('package_purchases', [ + 'user_id' => $user->id, + 'tenant_id' => $tenant->id, + 'package_id' => $freePackage->id, + ]); + } + + public function test_paid_package_creates_stripe_session() + { + $paidPackage = Package::factory()->create(['price' => 10]); + $user = User::factory()->create(['email_verified_at' => now()]); + $tenant = Tenant::factory()->create(['user_id' => $user->id]); + Auth::login($user); + + $response = $this->get(route('buy.packages', $paidPackage->id)); + + $response->assertStatus(302); // Redirect to Stripe + $this->assertStringContainsString('checkout.stripe.com', $response->headers->get('Location')); + } +} \ No newline at end of file diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php new file mode 100644 index 0000000..70e62a1 --- /dev/null +++ b/tests/Feature/RegistrationTest.php @@ -0,0 +1,130 @@ +create(['price' => 0]); + + $response = $this->post(route('register.store'), [ + 'name' => 'Test User', + 'username' => 'testuser', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'first_name' => 'Test', + 'last_name' => 'User', + 'address' => 'Test Address', + 'phone' => '123456789', + 'privacy_consent' => true, + 'package_id' => $freePackage->id, + ]); + + $response->assertRedirect(route('verification.notice')); + + $this->assertDatabaseHas('users', [ + 'username' => 'testuser', + 'email' => 'test@example.com', + 'first_name' => 'Test', + 'last_name' => 'User', + 'address' => 'Test Address', + 'phone' => '123456789', + ]); + + $user = User::where('email', 'test@example.com')->first(); + $this->assertNotNull($user->tenant); + $this->assertDatabaseHas('tenants', [ + 'user_id' => $user->id, + 'name' => 'Test User', + ]); + + $this->assertDatabaseHas('tenant_packages', [ + 'tenant_id' => $user->tenant->id, + 'package_id' => $freePackage->id, + ]); + } + + public function test_registration_without_package() + { + $response = $this->post(route('register.store'), [ + 'name' => 'Test User', + 'username' => 'testuser2', + 'email' => 'test2@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'first_name' => 'Test', + 'last_name' => 'User', + 'address' => 'Test Address', + 'phone' => '123456789', + 'privacy_consent' => true, + ]); + + $response->assertRedirect(route('verification.notice')); + + $user = User::where('email', 'test2@example.com')->first(); + $this->assertNotNull($user->tenant); + $this->assertDatabaseMissing('tenant_packages', [ + 'tenant_id' => $user->tenant->id, + ]); + } + + public function test_registration_validation_fails() + { + $response = $this->post(route('register.store'), [ + 'name' => '', + 'username' => '', + 'email' => 'invalid', + 'password' => 'short', + 'password_confirmation' => 'different', + 'first_name' => '', + 'last_name' => '', + 'address' => '', + 'phone' => '', + 'privacy_consent' => false, + ]); + + $response->assertSessionHasErrors([ + 'name', 'username', 'email', 'password', 'first_name', 'last_name', 'address', 'phone', 'privacy_consent', + ]); + } + + public function test_registered_event_sends_welcome_email() + { + Mail::fake(); + + $freePackage = Package::factory()->create(['price' => 0]); + + $response = $this->post(route('register.store'), [ + 'name' => 'Test User', + 'username' => 'testuser3', + 'email' => 'test3@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'first_name' => 'Test', + 'last_name' => 'User', + 'address' => 'Test Address', + 'phone' => '123456789', + 'privacy_consent' => true, + 'package_id' => $freePackage->id, + ]); + + Mail::assertQueued(Welcome::class, function ($mail) { + return $mail->to[0]['address'] === 'test3@example.com'; + }); + } +} \ No newline at end of file