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:
211
app/Filament/Resources/EventPurchaseResource.php
Normal file
211
app/Filament/Resources/EventPurchaseResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user