übergang auf pakete, integration von stripe und paypal, blog hinzugefügt.

This commit is contained in:
Codex Agent
2025-09-29 07:59:39 +02:00
parent 0a643c3e4d
commit e52a4005aa
83 changed files with 4284 additions and 629 deletions

View File

@@ -0,0 +1,78 @@
<?php
namespace App\Console\Commands;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\User;
use App\Models\TenantPackage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
class MigrateLegacyPurchases extends Command
{
protected $signature = 'packages:migrate-legacy';
protected $description = 'Migrate legacy purchases to new system with temp tenants';
public function handle()
{
$legacyPurchases = PackagePurchase::whereNull('tenant_id')->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;
}
}

View File

@@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum PackageType: string
{
case ENDCUSTOMER = 'endcustomer';
case RESELLER = 'reseller';
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Pages\Auth;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Pages\Page;
use Filament\Actions\Action;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class Login extends Page implements HasForms
{
use InteractsWithForms;
protected string $view = 'filament.pages.auth.login';
public function getFormSchema(): array
{
return [
TextInput::make('data.username_or_email')
->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;
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Filament\Pages;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Components\Component;
use Filament\Schemas\Schema;
class SuperAdminProfile extends BaseEditProfile
{
protected function getUsernameFormComponent(): Component
{
return TextInput::make('username')
->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(),
]);
}
}

View File

@@ -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

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePackage extends CreateRecord
{
protected static string $resource = PackageResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPackage extends EditRecord
{
protected static string $resource = PackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PackageResource\Pages;
use App\Filament\Resources\PackageResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPackages extends ListRecords
{
protected static string $resource = PackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,199 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PurchaseResource\Pages;
use App\Models\PackagePurchase;
use Filament\Actions\Action;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class PurchaseResource extends Resource
{
protected static ?string $model = PackagePurchase::class;
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-shopping-cart';
public static function getNavigationGroup(): string
{
return 'Billing';
}
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return $schema
->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
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePurchase extends CreateRecord
{
protected static string $resource = PurchaseResource::class;
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditPurchase extends EditRecord
{
protected static string $resource = PurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPurchases extends ListRecords
{
protected static string $resource = PurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Filament\Resources\PurchaseResource\Pages;
use App\Filament\Resources\PurchaseResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewPurchase extends ViewRecord
{
protected static string $resource = PurchaseResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
Actions\DeleteAction::make(),
Actions\Action::make('refund')
->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
}),
];
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\TenantPackageResource\Pages;
use App\Models\TenantPackage;
use Filament\Schemas\Schema;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\Toggle;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\CreateAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use UnitEnum;
use BackedEnum;
class TenantPackageResource extends Resource
{
protected static ?string $model = TenantPackage::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-shopping-bag';
protected static ?string $navigationLabel = 'Packages';
protected static ?string $slug = 'tenant-packages';
public static function form(Schema $form): Schema
{
return $form
->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'),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\CreateRecord;
use Illuminate\Support\Facades\Auth;
class CreateTenantPackage extends CreateRecord
{
protected static string $resource = TenantPackageResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['tenant_id'] = Auth::user()->tenant_id;
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditTenantPackage extends EditRecord
{
protected static string $resource = TenantPackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\ViewAction::make(),
Actions\DeleteAction::make(),
];
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
class ListTenantPackages extends ListRecords
{
protected static string $resource = TenantPackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
protected function getTableQuery(): Builder
{
return parent::getTableQuery()->where('tenant_id', Auth::user()->tenant_id);
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Filament\Resources\TenantPackageResource\Pages;
use App\Filament\Resources\TenantPackageResource;
use Filament\Actions;
use Filament\Resources\Pages\ViewRecord;
class ViewTenantPackage extends ViewRecord
{
protected static string $resource = TenantPackageResource::class;
protected function getHeaderActions(): array
{
return [
Actions\EditAction::make(),
Actions\DeleteAction::make(),
];
}
}

View File

@@ -38,6 +38,8 @@ class TenantResource extends Resource
public static function form(Schema $form): Schema
{
\Illuminate\Support\Facades\Log::info('TenantResource form() method called');
return $form->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,

View File

@@ -0,0 +1,132 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Models\User;
use Filament\Schemas\Schema;
use Filament\Forms\Form;
use Filament\Forms\Components\Section;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Icons\Icon;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\ActionGroup;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\EditAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Request;
use BackedEnum;
class UserResource extends Resource
{
protected static ?string $model = User::class;
protected static BackedEnum|string|null $navigationIcon = 'heroicon-o-user-circle';
protected static ?string $navigationLabel = 'Users';
protected static ?string $slug = 'users';
public static function form(Schema $form): Schema
{
return $form
->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'),
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Hash;
class EditUser extends EditRecord
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
protected function mutateFormDataBeforeSave(array $data): array
{
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
return $data;
}
protected function getRedirectUrl(): string
{
return $this->getResource()::getUrl('index');
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
use Filament\Tables;
use Filament\Tables\Table;
class ListUsers extends ListRecords
{
protected static string $resource = UserResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
public function table(Table $table): Table
{
return $table
->recordClasses(fn (User $record) => $record->id === auth()->id() ? 'border-2 border-blue-500' : '')
->poll('30s');
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Auth\Pages\EditProfile as BaseEditProfile;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Select;
use Filament\Schemas\Schema;
use Illuminate\Support\Facades\Log;
class EditProfile extends BaseEditProfile
{
public function mount(): void
{
Log::info('EditProfile class loaded for superadmin');
parent::mount();
}
public function form(Schema $schema): Schema
{
return $schema
->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(),
]);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Filament\SuperAdmin\Pages\Auth;
use Filament\Forms\Components\Checkbox;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Concerns\InteractsWithForms;
use Filament\Forms\Contracts\HasForms;
use Filament\Auth\Pages\Login as BaseLogin;
use Filament\Auth\Http\Responses\Contracts\LoginResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class Login extends BaseLogin implements HasForms
{
use InteractsWithForms;
public function authenticate(): ?LoginResponse
{
$data = $this->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'),
];
}
}

View File

@@ -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;

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\Package;
use App\Models\TenantPackage;
use App\Models\User;
use App\Models\Tenant;
use Illuminate\Auth\Events\Registered;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\Support\Str;
use Illuminate\Http\RedirectResponse;
use Illuminate\View\View;
class MarketingRegisterController extends Controller
{
/**
* Show the registration form.
*/
public function create(Request $request): View
{
$package = null;
if ($request->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');
}
}

View File

@@ -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);

View File

@@ -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');
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use App\Models\TenantPackage;
use App\Models\Package;
use PaypalServerSdkLib\PaypalServerSdkClientBuilder;
use PaypalServerSdkLib\Auth\ClientCredentialsAuthCredentialsBuilder;
use PaypalServerSdkLib\Environment;
use PaypalServerSdkLib\Logging\LoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\RequestLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\ResponseLoggingConfigurationBuilder;
use PaypalServerSdkLib\Logging\LogLevel;
use PaypalServerSdkLib\Orders\OrderRequestBuilder;
use PaypalServerSdkLib\Orders\CheckoutPaymentIntent;
use PaypalServerSdkLib\Orders\PurchaseUnitRequestBuilder;
use PaypalServerSdkLib\Orders\AmountWithBreakdownBuilder;
use PaypalServerSdkLib\Orders\ApplicationContextBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriptionRequestBuilder;
use PaypalServerSdkLib\Subscriptions\SubscriberBuilder;
use PaypalServerSdkLib\Subscriptions\NameBuilder;
use PaypalServerSdkLib\Subscriptions\ApplicationContextSubscriptionBuilder;
use PaypalServerSdkLib\Subscriptions\ShippingPreference;
class PayPalController extends Controller
{
private $client;
public function __construct()
{
$clientId = config('services.paypal.client_id');
$clientSecret = config('services.paypal.secret');
$this->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);
}
}
}

View File

@@ -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);

View File

@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules;
use Illuminate\View\View;
use Illuminate\Http\RedirectResponse;
class ProfileController extends Controller
{
/**
* Display the user's profile form.
*/
public function edit(Request $request): View
{
return view('profile.edit', [
'user' => $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');
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace App\Http\Controllers;
use App\Models\PackagePurchase;
use App\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Stripe\Stripe;
use Stripe\PaymentIntent;
use Stripe\Subscription;
class StripeController extends Controller
{
public function __construct()
{
Stripe::setApiKey(config('services.stripe.secret'));
}
public function createPaymentIntent(Request $request)
{
$request->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;
}
}

View File

@@ -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]);
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Http\Middleware;
use App\Models\Event;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class PackageMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$tenant = $request->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);
}
}

View File

@@ -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()

42
app/Mail/Welcome.php Normal file
View File

@@ -0,0 +1,42 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class Welcome extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public User $user)
{
//
}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Welcome to Fotospiel!',
);
}
public function content(): Content
{
return new Content(
view: 'emails.welcome',
with: [
'user' => $this->user,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -18,6 +18,8 @@ class Event extends Model
'date' => 'datetime',
'settings' => 'array',
'is_active' => 'boolean',
'name' => 'array',
'description' => 'array',
];
public function tenant(): BelongsTo

View File

@@ -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
{

View File

@@ -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();
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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
;
}

View File

@@ -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