Implement multi-tenancy support with OAuth2 authentication for tenant admins, Stripe integration for event purchases and credits ledger, new Filament resources for event purchases, updated API routes and middleware for tenant isolation and token guarding, added factories/seeders/migrations for new models (Tenant, EventPurchase, OAuth entities, etc.), enhanced tests, and documentation updates. Removed outdated DemoAchievementsSeeder.

This commit is contained in:
2025-09-17 19:56:54 +02:00
parent 5fbb9cb240
commit 42d6e98dff
84 changed files with 6125 additions and 155 deletions

View File

@@ -0,0 +1,211 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\EventPurchaseResource\Pages;
use App\Models\EventPurchase;
use App\Exports\EventPurchaseExporter;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Actions\Action;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ExportBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Support\Facades\Log;
use BackedEnum;
use UnitEnum;
class EventPurchaseResource extends Resource
{
protected static ?string $model = EventPurchase::class;
public static function getNavigationIcon(): string
{
return '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
->components([
Select::make('tenant_id')
->label('Tenant')
->relationship('tenant', 'name')
->searchable()
->preload()
->required(),
Select::make('package_id')
->label('Paket')
->options([
'starter_pack' => 'Starter Pack (5 Events)',
'pro_pack' => 'Pro Pack (20 Events)',
'lifetime_unlimited' => 'Lifetime Unlimited',
'monthly_pro' => 'Pro Subscription',
'monthly_agency' => 'Agency Subscription',
])
->required(),
TextInput::make('credits_added')
->label('Credits hinzugefügt')
->numeric()
->required()
->minValue(0),
TextInput::make('price')
->label('Preis')
->money('EUR')
->required(),
Select::make('platform')
->label('Plattform')
->options([
'ios' => 'iOS',
'android' => 'Android',
'web' => 'Web',
'manual' => 'Manuell',
])
->required(),
TextInput::make('transaction_id')
->label('Transaktions-ID')
->maxLength(255),
Textarea::make('reason')
->label('Beschreibung')
->maxLength(65535)
->columnSpanFull(),
])
->columns(2);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('tenant.name')
->label('Tenant')
->searchable()
->sortable(),
TextColumn::make('package_id')
->label('Paket')
->badge()
->color(fn (string $state): string => match($state) {
'starter_pack' => 'info',
'pro_pack' => 'success',
'lifetime_unlimited' => 'danger',
'monthly_pro' => 'warning',
default => 'gray',
}),
TextColumn::make('credits_added')
->label('Credits')
->badge()
->color('success'),
TextColumn::make('price')
->label('Preis')
->money('EUR')
->sortable(),
TextColumn::make('platform')
->badge()
->color(fn (string $state): string => match($state) {
'ios' => 'info',
'android' => 'success',
'web' => 'warning',
'manual' => 'gray',
}),
TextColumn::make('purchased_at')
->dateTime()
->sortable(),
TextColumn::make('transaction_id')
->copyable()
->toggleable(),
])
->filters([
Filter::make('created_at')
->form([
DatePicker::make('started_from'),
DatePicker::make('ended_before'),
])
->query(function (Builder $query, array $data): Builder {
return $query
->when(
$data['started_from'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '>=', $date),
)
->when(
$data['ended_before'],
fn (Builder $query, $date): Builder => $query->whereDate('created_at', '<=', $date),
);
}),
SelectFilter::make('platform')
->options([
'ios' => 'iOS',
'android' => 'Android',
'web' => 'Web',
'manual' => 'Manuell',
]),
SelectFilter::make('package_id')
->options([
'starter_pack' => 'Starter Pack',
'pro_pack' => 'Pro Pack',
'lifetime_unlimited' => 'Lifetime',
'monthly_pro' => 'Pro Subscription',
'monthly_agency' => 'Agency Subscription',
]),
TernaryFilter::make('successful')
->label('Erfolgreich')
->trueLabel('Ja')
->falseLabel('Nein')
->placeholder('Alle')
->query(fn (Builder $query): Builder => $query->whereNotNull('transaction_id')),
])
->actions([
ViewAction::make(),
Action::make('refund')
->label('Rückerstattung')
->color('danger')
->icon('heroicon-o-arrow-uturn-left')
->requiresConfirmation()
->visible(fn (EventPurchase $record): bool => $record->transaction_id && is_null($record->refunded_at))
->action(function (EventPurchase $record) {
$record->update(['refunded_at' => now()]);
$record->tenant->decrement('event_credits_balance', $record->credits_added);
Log::info('Refund processed for purchase ID: ' . $record->id);
}),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
ExportBulkAction::make()
->label('Export CSV')
->exporter(EventPurchaseExporter::class),
]),
])
->emptyStateHeading('Keine Käufe gefunden')
->emptyStateDescription('Erstelle den ersten Kauf oder überprüfe die Filter.');
}
public static function getPages(): array
{
return [
'index' => Pages\ListEventPurchases::route('/'),
'create' => Pages\CreateEventPurchase::route('/create'),
'view' => Pages\ViewEventPurchase::route('/{record}'),
'edit' => Pages\EditEventPurchase::route('/{record}/edit'),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,11 @@ use Filament\Schemas\Schema;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Components\KeyValue;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\DateTimePicker;
use Filament\Tables\Columns\IconColumn;
use App\Filament\Resources\TenantResource\RelationManagers\PurchasesRelationManager;
use Filament\Resources\RelationManagers\RelationGroup;
use UnitEnum;
use BackedEnum;
@@ -50,6 +55,28 @@ class TenantResource extends Resource
->label(__('admin.tenants.fields.event_credits_balance'))
->numeric()
->default(0),
Select::make('subscription_tier')
->label(__('admin.tenants.fields.subscription_tier'))
->options([
'free' => 'Free',
'starter' => 'Starter (€4.99/mo)',
'pro' => 'Pro (€14.99/mo)',
'agency' => 'Agency (€19.99/mo)',
'lifetime' => 'Lifetime (€49.99)'
])
->default('free'),
DateTimePicker::make('subscription_expires_at')
->label(__('admin.tenants.fields.subscription_expires_at')),
TextInput::make('total_revenue')
->label(__('admin.tenants.fields.total_revenue'))
->money('EUR')
->readOnly(),
Toggle::make('is_active')
->label(__('admin.tenants.fields.is_active'))
->default(true),
Toggle::make('is_suspended')
->label(__('admin.tenants.fields.is_suspended'))
->default(false),
KeyValue::make('features')
->label(__('admin.tenants.fields.features'))
->keyLabel(__('admin.common.key'))
@@ -65,13 +92,60 @@ class TenantResource extends Resource
Tables\Columns\TextColumn::make('name')->searchable()->sortable(),
Tables\Columns\TextColumn::make('slug')->searchable(),
Tables\Columns\TextColumn::make('contact_email'),
Tables\Columns\TextColumn::make('event_credits_balance')->label(__('admin.common.credits')),
Tables\Columns\TextColumn::make('event_credits_balance')
->label(__('admin.common.credits'))
->badge()
->color(fn ($state) => $state < 5 ? 'warning' : 'success'),
Tables\Columns\TextColumn::make('subscription_tier')
->badge()
->color(fn (string $state): string => match($state) {
'free' => 'gray',
'starter' => 'info',
'pro' => 'success',
'agency' => 'warning',
'lifetime' => 'danger',
}),
Tables\Columns\TextColumn::make('total_revenue')
->money('EUR')
->sortable(),
Tables\Columns\IconColumn::make('is_active')
->boolean()
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
Tables\Columns\TextColumn::make('last_activity_at')->since()->label(__('admin.common.last_activity')),
Tables\Columns\TextColumn::make('created_at')->dateTime()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->actions([
Actions\EditAction::make(),
Actions\Action::make('add_credits')
->label('Credits hinzufügen')
->icon('heroicon-o-plus')
->form([
Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1),
Forms\Components\Textarea::make('reason')->label('Grund')->rows(3),
])
->action(function (Tenant $record, array $data) {
$record->increment('event_credits_balance', $data['credits']);
\App\Models\EventPurchase::create([
'tenant_id' => $record->id,
'package_id' => 'manual_adjustment',
'credits_added' => $data['credits'],
'price' => 0,
'platform' => 'manual',
'transaction_id' => null,
'reason' => $data['reason'],
]);
}),
Actions\Action::make('suspend')
->label('Suspendieren')
->color('danger')
->requiresConfirmation()
->action(fn (Tenant $record) => $record->update(['is_suspended' => true])),
Actions\Action::make('export')
->label('Daten exportieren')
->icon('heroicon-o-arrow-down-tray')
->url(fn (Tenant $record) => route('admin.tenants.export', $record))
->openUrlInNewTab(),
])
->bulkActions([
Actions\DeleteBulkAction::make(),

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Filament\Resources\TenantResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Components\DatePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Textarea;
use Filament\Schemas\Schema;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\BulkActionGroup;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
class PurchasesRelationManager extends RelationManager
{
protected static string $relationship = 'eventPurchases';
protected static ?string $title = 'Käufe';
public function form(Schema $form): Schema
{
return $form
->schema([
Select::make('package_id')
->label('Paket')
->options([
'starter_pack' => 'Starter Pack (5 Events)',
'pro_pack' => 'Pro Pack (20 Events)',
'lifetime_unlimited' => 'Lifetime Unlimited',
'monthly_pro' => 'Pro Subscription',
'monthly_agency' => 'Agency Subscription',
])
->required(),
TextInput::make('credits_added')
->label('Credits hinzugefügt')
->numeric()
->required()
->minValue(0),
TextInput::make('price')
->label('Preis')
->money('EUR')
->required(),
Select::make('platform')
->label('Plattform')
->options([
'ios' => 'iOS',
'android' => 'Android',
'web' => 'Web',
'manual' => 'Manuell',
])
->required(),
TextInput::make('transaction_id')
->label('Transaktions-ID')
->maxLength(255),
Textarea::make('reason')
->label('Beschreibung')
->maxLength(65535)
->columnSpanFull(),
])
->columns(2);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('package_id')
->columns([
TextColumn::make('package_id')
->badge()
->color(fn (string $state): string => match($state) {
'starter_pack' => 'info',
'pro_pack' => 'success',
'lifetime_unlimited' => 'danger',
'monthly_pro' => 'warning',
default => 'gray',
}),
TextColumn::make('credits_added')
->label('Credits')
->badge()
->color('success'),
TextColumn::make('price')
->money('EUR'),
TextColumn::make('platform')
->badge()
->color(fn (string $state): string => match($state) {
'ios' => 'info',
'android' => 'success',
'web' => 'warning',
'manual' => 'gray',
}),
TextColumn::make('purchased_at')
->dateTime()
->sortable(),
TextColumn::make('transaction_id')
->copyable()
->toggleable(),
])
->filters([
SelectFilter::make('platform')
->options(['ios' => 'iOS', 'android' => 'Android', 'web' => 'Web', 'manual' => 'Manuell']),
SelectFilter::make('package_id')
->options([
'starter_pack' => 'Starter Pack',
'pro_pack' => 'Pro Pack',
'lifetime_unlimited' => 'Lifetime',
'monthly_pro' => 'Pro Subscription',
'monthly_agency' => 'Agency Subscription',
]),
])
->headerActions([])
->actions([
ViewAction::make(),
])
->bulkActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
}