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