feat(packages): implement package-based business model
This commit is contained in:
93
app/Console/Commands/MigrateToPackages.php
Normal file
93
app/Console/Commands/MigrateToPackages.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Event;
|
||||
use App\Models\Package;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class MigrateToPackages extends Command
|
||||
{
|
||||
protected $signature = 'packages:migrate';
|
||||
protected $description = 'Migrate existing credits data to packages';
|
||||
|
||||
public function handle()
|
||||
{
|
||||
DB::transaction(function () {
|
||||
// Find Free package for endcustomer
|
||||
$freePackage = Package::where('name', 'Free / Test')->where('type', 'endcustomer')->first();
|
||||
if (!$freePackage) {
|
||||
$this->error('Free package not found. Run seeder first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
$resellerPackage = Package::where('name', 'Reseller S')->where('type', 'reseller')->first();
|
||||
if (!$resellerPackage) {
|
||||
$this->error('Reseller package not found. Run seeder first.');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Migrate tenants with credits to tenant_packages (reseller free)
|
||||
$tenants = Tenant::where('event_credits_balance', '>', 0)->get();
|
||||
foreach ($tenants as $tenant) {
|
||||
$initialEvents = floor($tenant->event_credits_balance / 100); // Arbitrary conversion
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $resellerPackage->id,
|
||||
'price' => 0,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
]);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $resellerPackage->id,
|
||||
'type' => 'reseller_subscription',
|
||||
'provider_id' => 'migration',
|
||||
'purchased_price' => 0,
|
||||
'metadata' => ['migrated_credits' => $tenant->event_credits_balance],
|
||||
]);
|
||||
|
||||
$this->info("Migrated tenant {$tenant->name} with {$tenant->event_credits_balance} credits to Reseller S package.");
|
||||
}
|
||||
|
||||
// Migrate events to event_packages (free)
|
||||
$events = Event::all();
|
||||
foreach ($events as $event) {
|
||||
EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $freePackage->id,
|
||||
'purchased_price' => 0,
|
||||
'purchased_at' => $event->created_at,
|
||||
'used_photos' => 0,
|
||||
]);
|
||||
|
||||
PackagePurchase::create([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $freePackage->id,
|
||||
'type' => 'endcustomer_event',
|
||||
'provider_id' => 'migration',
|
||||
'purchased_price' => 0,
|
||||
'metadata' => ['migrated_from_credits' => true],
|
||||
]);
|
||||
|
||||
$this->info("Migrated event {$event->name} to Free package.");
|
||||
}
|
||||
|
||||
// Clear old credits data (assume drop migration already run)
|
||||
Tenant::where('event_credits_balance', '>', 0)->update(['event_credits_balance' => 0]);
|
||||
|
||||
$this->info('Migration completed successfully.');
|
||||
});
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,14 @@ use Filament\Forms\Form;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\KeyValue;
|
||||
use Filament\Forms\Components\Select;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
|
||||
use App\Filament\Resources\EventResource\RelationManagers\EventPackagesRelationManager;
|
||||
|
||||
class EventResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Event::class;
|
||||
@@ -57,6 +59,12 @@ class EventResource extends Resource
|
||||
->label(__('admin.events.fields.type'))
|
||||
->options(EventType::all()->pluck('name', 'id'))
|
||||
->searchable(),
|
||||
Select::make('package_id')
|
||||
->label(__('admin.events.fields.package'))
|
||||
->options(\App\Models\Package::where('type', 'endcustomer')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
TextInput::make('default_locale')
|
||||
->label(__('admin.events.fields.default_locale'))
|
||||
->default('de')
|
||||
@@ -82,6 +90,18 @@ class EventResource extends Resource
|
||||
Tables\Columns\TextColumn::make('date')->date(),
|
||||
Tables\Columns\IconColumn::make('is_active')->boolean(),
|
||||
Tables\Columns\TextColumn::make('default_locale'),
|
||||
Tables\Columns\TextColumn::make('eventPackage.package.name')
|
||||
->label(__('admin.events.table.package'))
|
||||
->badge()
|
||||
->color('success'),
|
||||
Tables\Columns\TextColumn::make('eventPackage.used_photos')
|
||||
->label(__('admin.events.table.used_photos'))
|
||||
->badge(),
|
||||
Tables\Columns\TextColumn::make('eventPackage.remaining_photos')
|
||||
->label(__('admin.events.table.remaining_photos'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn ($record) => $record->eventPackage?->remaining_photos ?? 0),
|
||||
Tables\Columns\TextColumn::make('join')->label(__('admin.events.table.join'))
|
||||
->getStateUsing(fn($record) => url("/e/{$record->slug}"))
|
||||
->copyable()
|
||||
@@ -117,4 +137,11 @@ class EventResource extends Resource
|
||||
'edit' => Pages\EditEvent::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
EventPackagesRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EventResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use App\Models\EventPackage;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Schemas\Schema;
|
||||
|
||||
class EventPackagesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'eventPackages';
|
||||
|
||||
public function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
Select::make('package_id')
|
||||
->label('Package')
|
||||
->relationship('package', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
TextInput::make('purchased_price')
|
||||
->label('Kaufpreis')
|
||||
->prefix('€')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->required(),
|
||||
TextInput::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
TextInput::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->readOnly(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
->label('Package')
|
||||
->badge()
|
||||
->color('success'),
|
||||
TextColumn::make('used_photos')
|
||||
->label('Verwendete Fotos')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_photos')
|
||||
->label('Verbleibende Fotos')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_photos),
|
||||
TextColumn::make('used_guests')
|
||||
->label('Verwendete Gäste')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_guests')
|
||||
->label('Verbleibende Gäste')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (EventPackage $record) => $record->remaining_guests),
|
||||
TextColumn::make('expires_at')
|
||||
->label('Ablauf')
|
||||
->dateTime()
|
||||
->badge()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success'),
|
||||
TextColumn::make('purchased_price')
|
||||
->label('Preis')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getRelationExistenceQuery(
|
||||
Builder $query,
|
||||
string $relationshipName,
|
||||
?string $ownerKeyName,
|
||||
mixed $ownerKeyValue,
|
||||
): Builder {
|
||||
return $query;
|
||||
}
|
||||
|
||||
public static function getTitle(Model $ownerRecord, string $pageClass): string
|
||||
{
|
||||
return __('admin.events.relation_managers.event_packages.title');
|
||||
}
|
||||
|
||||
public function getTableQuery(): Builder | Relation
|
||||
{
|
||||
return parent::getTableQuery()
|
||||
->with('package');
|
||||
}
|
||||
}
|
||||
153
app/Filament/Resources/PackageResource.php
Normal file
153
app/Filament/Resources/PackageResource.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PackageResource\Pages;
|
||||
use App\Models\Package;
|
||||
use Filament\Forms;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\Repeater;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
|
||||
class PackageResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Package::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-cube';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = null;
|
||||
|
||||
protected static ?int $navigationSort = 5;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema->schema([
|
||||
TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Select::make('type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'endcustomer' => 'Endcustomer',
|
||||
'reseller' => 'Reseller',
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('price')
|
||||
->label('Price')
|
||||
->prefix('€')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->required()
|
||||
->default(0),
|
||||
TextInput::make('max_photos')
|
||||
->label('Max Photos')
|
||||
->numeric()
|
||||
->nullable(),
|
||||
TextInput::make('max_guests')
|
||||
->label('Max Guests')
|
||||
->numeric()
|
||||
->nullable(),
|
||||
TextInput::make('gallery_days')
|
||||
->label('Gallery Days')
|
||||
->numeric()
|
||||
->nullable(),
|
||||
TextInput::make('max_tasks')
|
||||
->label('Max Tasks')
|
||||
->numeric()
|
||||
->nullable(),
|
||||
Toggle::make('watermark_allowed')
|
||||
->label('Watermark Allowed')
|
||||
->default(true),
|
||||
Toggle::make('branding_allowed')
|
||||
->label('Branding Allowed')
|
||||
->default(false),
|
||||
TextInput::make('max_events_per_year')
|
||||
->label('Max Events per Year')
|
||||
->numeric()
|
||||
->nullable(),
|
||||
Repeater::make('features')
|
||||
->label('Features')
|
||||
->schema([
|
||||
TextInput::make('key')
|
||||
->label('Feature Key'),
|
||||
TextInput::make('value')
|
||||
->label('Feature Value'),
|
||||
])
|
||||
->columns(2)
|
||||
->defaultItems(0),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
TextColumn::make('name')
|
||||
->label('Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'endcustomer' => 'info',
|
||||
'reseller' => 'warning',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('price')
|
||||
->label('Price')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
IconColumn::make('max_photos')
|
||||
->label('Max Photos')
|
||||
->icon('heroicon-o-photo')
|
||||
->color('primary'),
|
||||
TextColumn::make('features')
|
||||
->label('Features')
|
||||
->limit(50),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListPackages::route('/'),
|
||||
'create' => Pages\CreatePackage::route('/create'),
|
||||
'edit' => Pages\EditPackage::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,8 @@ 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 App\Filament\Resources\TenantResource\RelationManagers\PackagePurchasesRelationManager;
|
||||
use App\Filament\Resources\TenantResource\RelationManagers\TenantPackagesRelationManager;
|
||||
use Filament\Resources\RelationManagers\RelationGroup;
|
||||
use UnitEnum;
|
||||
use BackedEnum;
|
||||
@@ -52,28 +53,23 @@ class TenantResource extends Resource
|
||||
->email()
|
||||
->required()
|
||||
->maxLength(255),
|
||||
TextInput::make('event_credits_balance')
|
||||
->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'))
|
||||
->prefix('€')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->readOnly(),
|
||||
Select::make('active_reseller_package_id')
|
||||
->label(__('admin.tenants.fields.active_reseller_package'))
|
||||
->relationship('activeResellerPackage', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->nullable(),
|
||||
TextInput::make('remaining_events')
|
||||
->label(__('admin.tenants.fields.remaining_events'))
|
||||
->readOnly()
|
||||
->dehydrated(false)
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0),
|
||||
Toggle::make('is_active')
|
||||
->label(__('admin.tenants.fields.is_active'))
|
||||
->default(true),
|
||||
@@ -95,19 +91,21 @@ 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('activeResellerPackage.name')
|
||||
->label(__('admin.tenants.fields.active_package'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 5 ? 'warning' : 'success'),
|
||||
Tables\Columns\TextColumn::make('subscription_tier')
|
||||
->color('success'),
|
||||
Tables\Columns\TextColumn::make('remaining_events')
|
||||
->label(__('admin.tenants.fields.remaining_events'))
|
||||
->badge()
|
||||
->color(fn (string $state): string => match($state) {
|
||||
'free' => 'gray',
|
||||
'starter' => 'info',
|
||||
'pro' => 'success',
|
||||
'agency' => 'warning',
|
||||
'lifetime' => 'danger',
|
||||
}),
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->remaining_events ?? 0),
|
||||
Tables\Columns\TextColumn::make('activeResellerPackage.expires_at')
|
||||
->dateTime()
|
||||
->label(__('admin.tenants.fields.package_expires_at'))
|
||||
->badge()
|
||||
->color(fn ($state) => $state && $state->isPast() ? 'danger' : 'success')
|
||||
->getStateUsing(fn (Tenant $record) => $record->activeResellerPackage?->expires_at),
|
||||
Tables\Columns\TextColumn::make('total_revenue')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
@@ -120,23 +118,36 @@ class TenantResource extends Resource
|
||||
->filters([])
|
||||
->actions([
|
||||
Actions\EditAction::make(),
|
||||
Actions\Action::make('add_credits')
|
||||
->label('Credits hinzufügen')
|
||||
Actions\Action::make('add_package')
|
||||
->label('Package hinzufügen')
|
||||
->icon('heroicon-o-plus')
|
||||
->form([
|
||||
Forms\Components\TextInput::make('credits')->numeric()->required()->minValue(1),
|
||||
Select::make('package_id')
|
||||
->label('Package')
|
||||
->options(\App\Models\Package::where('type', 'reseller')->pluck('name', 'id'))
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Forms\Components\DateTimePicker::make('expires_at')
|
||||
->label('Ablaufdatum')
|
||||
->default(now()->addYear()),
|
||||
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([
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $record->id,
|
||||
'package_id' => 'manual_adjustment',
|
||||
'credits_added' => $data['credits'],
|
||||
'price' => 0,
|
||||
'platform' => 'manual',
|
||||
'transaction_id' => null,
|
||||
'reason' => $data['reason'],
|
||||
'package_id' => $data['package_id'],
|
||||
'expires_at' => $data['expires_at'],
|
||||
'active' => true,
|
||||
'reason' => $data['reason'] ?? null,
|
||||
]);
|
||||
\App\Models\PackagePurchase::create([
|
||||
'tenant_id' => $record->id,
|
||||
'package_id' => $data['package_id'],
|
||||
'provider_id' => 'manual',
|
||||
'type' => 'reseller_subscription',
|
||||
'purchased_price' => 0,
|
||||
'metadata' => ['reason' => $data['reason'] ?? 'manual assignment'],
|
||||
]);
|
||||
}),
|
||||
Actions\Action::make('suspend')
|
||||
@@ -164,4 +175,12 @@ class TenantResource extends Resource
|
||||
'edit' => Pages\EditTenant::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
TenantPackagesRelationManager::class,
|
||||
PackagePurchasesRelationManager::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
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\Columns\IconColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class PackagePurchasesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'packagePurchases';
|
||||
|
||||
protected static ?string $title = 'Package-Käufe';
|
||||
|
||||
public function form(Schema $form): Schema
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('package_id')
|
||||
->label('Paket')
|
||||
->relationship('package', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
Select::make('type')
|
||||
->label('Typ')
|
||||
->options([
|
||||
'endcustomer_event' => 'Endkunden-Event',
|
||||
'reseller_subscription' => 'Reseller-Abo',
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('purchased_price')
|
||||
->label('Gekaufter Preis')
|
||||
->numeric()
|
||||
->step(0.01)
|
||||
->prefix('€')
|
||||
->required(),
|
||||
Select::make('provider_id')
|
||||
->label('Anbieter')
|
||||
->options([
|
||||
'stripe' => 'Stripe',
|
||||
'paypal' => 'PayPal',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
])
|
||||
->required(),
|
||||
TextInput::make('transaction_id')
|
||||
->label('Transaktions-ID')
|
||||
->maxLength(255),
|
||||
Toggle::make('refunded')
|
||||
->label('Rückerstattet'),
|
||||
Textarea::make('metadata')
|
||||
->label('Metadaten')
|
||||
->json()
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
->label('Paket')
|
||||
->badge()
|
||||
->color('success'),
|
||||
TextColumn::make('type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match($state) {
|
||||
'endcustomer_event' => 'info',
|
||||
'reseller_subscription' => 'success',
|
||||
default => 'gray',
|
||||
}),
|
||||
TextColumn::make('purchased_price')
|
||||
->money('EUR')
|
||||
->sortable(),
|
||||
TextColumn::make('provider_id')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match($state) {
|
||||
'stripe' => 'info',
|
||||
'paypal' => 'warning',
|
||||
'manual' => 'gray',
|
||||
'free' => 'success',
|
||||
}),
|
||||
TextColumn::make('transaction_id')
|
||||
->copyable()
|
||||
->toggleable(),
|
||||
TextColumn::make('metadata')
|
||||
->label('Metadaten')
|
||||
->toggleable(),
|
||||
IconColumn::make('refunded')
|
||||
->boolean()
|
||||
->color(fn (bool $state): string => $state ? 'danger' : 'success'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('type')
|
||||
->options([
|
||||
'endcustomer_event' => 'Endkunden-Event',
|
||||
'reseller_subscription' => 'Reseller-Abo',
|
||||
]),
|
||||
SelectFilter::make('provider_id')
|
||||
->options([
|
||||
'stripe' => 'Stripe',
|
||||
'paypal' => 'PayPal',
|
||||
'manual' => 'Manuell',
|
||||
'free' => 'Kostenlos',
|
||||
]),
|
||||
SelectFilter::make('refunded')
|
||||
->options([
|
||||
'1' => 'Rückerstattet',
|
||||
'0' => 'Nicht rückerstattet',
|
||||
]),
|
||||
])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\TenantResource\RelationManagers;
|
||||
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
|
||||
class TenantPackagesRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'tenantPackages';
|
||||
|
||||
protected static ?string $title = 'Reseller-Pakete';
|
||||
|
||||
public function form(Schema $form): Schema
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Select::make('package_id')
|
||||
->label('Paket')
|
||||
->relationship('package', 'name')
|
||||
->searchable()
|
||||
->preload()
|
||||
->required(),
|
||||
DateTimePicker::make('expires_at')
|
||||
->label('Ablaufdatum')
|
||||
->required(),
|
||||
Toggle::make('active')
|
||||
->label('Aktiv'),
|
||||
Textarea::make('reason')
|
||||
->label('Grund')
|
||||
->maxLength(65535)
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('package.name')
|
||||
->columns([
|
||||
TextColumn::make('package.name')
|
||||
->label('Paket')
|
||||
->badge()
|
||||
->color('success'),
|
||||
TextColumn::make('used_events')
|
||||
->label('Genutzte Events')
|
||||
->badge(),
|
||||
TextColumn::make('remaining_events')
|
||||
->label('Verbleibende Events')
|
||||
->badge()
|
||||
->color(fn ($state) => $state < 1 ? 'danger' : 'success')
|
||||
->getStateUsing(fn ($record) => $record->remaining_events),
|
||||
TextColumn::make('expires_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
IconColumn::make('active')
|
||||
->boolean()
|
||||
->color(fn (bool $state): string => $state ? 'success' : 'danger'),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('active')
|
||||
->options([
|
||||
'1' => 'Aktiv',
|
||||
'0' => 'Inaktiv',
|
||||
]),
|
||||
])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
EditAction::make(),
|
||||
Action::make('activate')
|
||||
->label('Aktivieren')
|
||||
->icon('heroicon-o-check-circle')
|
||||
->color('success')
|
||||
->action(fn ($record) => $record->update(['active' => true])),
|
||||
Action::make('deactivate')
|
||||
->label('Deaktivieren')
|
||||
->icon('heroicon-o-x-circle')
|
||||
->color('danger')
|
||||
->requiresConfirmation()
|
||||
->action(fn ($record) => $record->update(['active' => false])),
|
||||
])
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
116
app/Http/Controllers/Api/PackageController.php
Normal file
116
app/Http/Controllers/Api/PackageController.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
|
||||
class PackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$type = $request->query('type', 'endcustomer');
|
||||
$packages = Package::where('type', $type)
|
||||
->orderBy('price')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$package->features = json_decode($package->features ?? '[]', true);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $packages,
|
||||
'message' => "Packages for type '{$type}' loaded successfully.",
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchase(Request $request): JsonResponse
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer_event,reseller_subscription',
|
||||
'payment_method' => 'required|in:stripe,paypal',
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
]);
|
||||
|
||||
$package = Package::findOrFail($request->package_id);
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (!$tenant) {
|
||||
throw ValidationException::withMessages(['tenant' => 'Tenant not found.']);
|
||||
}
|
||||
|
||||
if ($package->price == 0) {
|
||||
// Free package - direct assignment
|
||||
return $this->handleFreePurchase($request, $package, $tenant);
|
||||
}
|
||||
|
||||
// Paid purchase
|
||||
return $this->handlePaidPurchase($request, $package, $tenant);
|
||||
}
|
||||
|
||||
private function handleFreePurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
DB::transaction(function () use ($request, $package, $tenant) {
|
||||
$purchaseData = [
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $request->event_id,
|
||||
'package_id' => $package->id,
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => $request->type,
|
||||
'metadata' => json_encode([
|
||||
'note' => 'Free package assigned',
|
||||
'ip' => $request->ip(),
|
||||
]),
|
||||
];
|
||||
|
||||
PackagePurchase::create($purchaseData);
|
||||
|
||||
if ($request->event_id) {
|
||||
// Assign to event
|
||||
\App\Models\EventPackage::create([
|
||||
'event_id' => $request->event_id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
} else {
|
||||
// Reseller subscription
|
||||
\App\Models\TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'expires_at' => now()->addYear(),
|
||||
'active' => true,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Free package assigned successfully.',
|
||||
'purchase' => ['package' => $package->name, 'type' => $request->type],
|
||||
], 201);
|
||||
}
|
||||
|
||||
private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse
|
||||
{
|
||||
$type = $request->type;
|
||||
|
||||
if ($type === 'reseller_subscription') {
|
||||
$response = (new StripeController())->createSubscription($request);
|
||||
return $response;
|
||||
} else {
|
||||
$response = (new StripeController())->createPaymentIntent($request);
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/Http/Controllers/Api/StripeController.php
Normal file
100
app/Http/Controllers/Api/StripeController.php
Normal file
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Stripe;
|
||||
use Stripe\PaymentIntent;
|
||||
use Stripe\Subscription;
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
use Stripe\Webhook;
|
||||
|
||||
class StripeController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
Stripe::setApiKey(config('services.stripe.secret'));
|
||||
}
|
||||
|
||||
public function createPaymentIntent(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'type' => 'required|in:endcustomer_event,reseller_subscription',
|
||||
'tenant_id' => 'nullable|exists:tenants,id', // For reseller
|
||||
'event_id' => 'nullable|exists:events,id', // For endcustomer
|
||||
]);
|
||||
|
||||
$package = \App\Models\Package::findOrFail($request->package_id);
|
||||
|
||||
$amount = $package->price * 100; // Cents
|
||||
|
||||
$metadata = [
|
||||
'package_id' => $package->id,
|
||||
'type' => $request->type,
|
||||
];
|
||||
|
||||
if ($request->tenant_id) {
|
||||
$metadata['tenant_id'] = $request->tenant_id;
|
||||
}
|
||||
|
||||
if ($request->event_id) {
|
||||
$metadata['event_id'] = $request->event_id;
|
||||
}
|
||||
|
||||
$intent = PaymentIntent::create([
|
||||
'amount' => $amount,
|
||||
'currency' => 'eur',
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'client_secret' => $intent->client_secret,
|
||||
]);
|
||||
}
|
||||
|
||||
public function createSubscription(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
'package_id' => 'required|exists:packages,id',
|
||||
'tenant_id' => 'required|exists:tenants,id',
|
||||
]);
|
||||
|
||||
$package = \App\Models\Package::findOrFail($request->package_id);
|
||||
$tenant = \App\Models\Tenant::findOrFail($request->tenant_id);
|
||||
|
||||
// Assume customer exists or create
|
||||
$customer = $tenant->stripe_customer_id ? \Stripe\Customer::retrieve($tenant->stripe_customer_id) : \Stripe\Customer::create([
|
||||
'email' => $tenant->email,
|
||||
'metadata' => ['tenant_id' => $tenant->id],
|
||||
]);
|
||||
|
||||
$subscription = Subscription::create([
|
||||
'customer' => $customer->id,
|
||||
'items' => [[
|
||||
'price' => $package->stripe_price_id, // Assume price ID set in package
|
||||
]],
|
||||
'metadata' => [
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
],
|
||||
]);
|
||||
|
||||
// Create initial tenant package
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'stripe_subscription_id' => $subscription->id,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'subscription_id' => $subscription->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
136
app/Http/Controllers/Api/StripeWebhookController.php
Normal file
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Stripe\Webhook;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
use Stripe\Exception\SignatureVerificationException;
|
||||
|
||||
class StripeWebhookController extends Controller
|
||||
{
|
||||
public function handleWebhook(Request $request)
|
||||
{
|
||||
$payload = $request->getContent();
|
||||
$sigHeader = $request->header('Stripe-Signature');
|
||||
$endpointSecret = config('services.stripe.webhook_secret');
|
||||
|
||||
try {
|
||||
$event = Webhook::constructEvent(
|
||||
$payload, $sigHeader, $endpointSecret
|
||||
);
|
||||
} catch (SignatureVerificationException $e) {
|
||||
return response()->json(['error' => 'Invalid signature'], 400);
|
||||
} catch (\UnexpectedValueException $e) {
|
||||
return response()->json(['error' => 'Invalid payload'], 400);
|
||||
}
|
||||
|
||||
// Handle the event
|
||||
switch ($event['type']) {
|
||||
case 'payment_intent.succeeded':
|
||||
$paymentIntent = $event['data']['object'];
|
||||
$this->handlePaymentIntentSucceeded($paymentIntent);
|
||||
break;
|
||||
|
||||
case 'invoice.paid':
|
||||
$invoice = $event['data']['object'];
|
||||
$this->handleInvoicePaid($invoice);
|
||||
break;
|
||||
|
||||
default:
|
||||
\Log::info('Unhandled Stripe event', ['type' => $event['type']]);
|
||||
}
|
||||
|
||||
return response()->json(['status' => 'success'], 200);
|
||||
}
|
||||
|
||||
private function handlePaymentIntentSucceeded(array $paymentIntent)
|
||||
{
|
||||
$metadata = $paymentIntent['metadata'];
|
||||
$packageId = $metadata['package_id'];
|
||||
$type = $metadata['type'];
|
||||
|
||||
\DB::transaction(function () use ($paymentIntent, $metadata, $packageId, $type) {
|
||||
// Create purchase record
|
||||
$purchase = PackagePurchase::create([
|
||||
'package_id' => $packageId,
|
||||
'type' => $type,
|
||||
'provider_id' => 'stripe',
|
||||
'transaction_id' => $paymentIntent['id'],
|
||||
'purchased_price' => $paymentIntent['amount_received'] / 100,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
|
||||
if ($type === 'endcustomer_event') {
|
||||
$eventId = $metadata['event_id'];
|
||||
EventPackage::create([
|
||||
'event_id' => $eventId,
|
||||
'package_id' => $packageId,
|
||||
'package_purchase_id' => $purchase->id,
|
||||
'used_photos' => 0,
|
||||
'used_guests' => 0,
|
||||
'expires_at' => now()->addDays(30), // Default, or from package
|
||||
]);
|
||||
} elseif ($type === 'reseller_subscription') {
|
||||
$tenantId = $metadata['tenant_id'];
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'package_id' => $packageId,
|
||||
'package_purchase_id' => $purchase->id,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function handleInvoicePaid(array $invoice)
|
||||
{
|
||||
$subscription = $invoice['subscription'];
|
||||
$metadata = $subscription['metadata'] ?? [];
|
||||
|
||||
if (isset($metadata['tenant_id'])) {
|
||||
$tenantId = $metadata['tenant_id'];
|
||||
$packageId = $metadata['package_id'];
|
||||
|
||||
// Renew or create tenant package
|
||||
$tenantPackage = TenantPackage::where('tenant_id', $tenantId)
|
||||
->where('package_id', $packageId)
|
||||
->where('stripe_subscription_id', $subscription)
|
||||
->first();
|
||||
|
||||
if ($tenantPackage) {
|
||||
$tenantPackage->update([
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
} else {
|
||||
TenantPackage::create([
|
||||
'tenant_id' => $tenantId,
|
||||
'package_id' => $packageId,
|
||||
'stripe_subscription_id' => $subscription,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Create purchase record
|
||||
PackagePurchase::create([
|
||||
'package_id' => $packageId,
|
||||
'type' => 'reseller_subscription',
|
||||
'provider_id' => 'stripe',
|
||||
'transaction_id' => $invoice['id'],
|
||||
'purchased_price' => $invoice['amount_paid'] / 100,
|
||||
'metadata' => $metadata,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,15 +53,18 @@ class EventController extends Controller
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
}
|
||||
|
||||
if ($tenant->event_credits_balance < 1) {
|
||||
if (!$tenant->canCreateEvent()) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient event credits. Please purchase more credits.',
|
||||
'error' => 'No available package for creating events. Please purchase a package.',
|
||||
], 402);
|
||||
}
|
||||
|
||||
$validated = $request->validated();
|
||||
$tenantId = $tenant->id;
|
||||
|
||||
$packageId = $validated['package_id'] ?? 1; // Default to Free package ID 1
|
||||
unset($validated['package_id']);
|
||||
|
||||
$eventData = array_merge($validated, [
|
||||
'tenant_id' => $tenantId,
|
||||
'status' => $validated['status'] ?? 'draft',
|
||||
@@ -116,24 +119,43 @@ class EventController extends Controller
|
||||
|
||||
$eventData = Arr::only($eventData, $allowed);
|
||||
|
||||
$event = DB::transaction(function () use ($tenant, $eventData) {
|
||||
$event = DB::transaction(function () use ($tenant, $eventData, $packageId) {
|
||||
$event = Event::create($eventData);
|
||||
|
||||
$note = sprintf('Event create: %s', $event->slug);
|
||||
if (! $tenant->decrementCredits(1, 'event_create', $note, null)) {
|
||||
throw new \RuntimeException('Unable to deduct credits');
|
||||
// Create EventPackage and PackagePurchase for Free package
|
||||
$package = \App\Models\Package::findOrFail($packageId);
|
||||
$eventPackage = \App\Models\EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
]);
|
||||
|
||||
\App\Models\PackagePurchase::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $packageId,
|
||||
'provider_id' => 'free',
|
||||
'price' => $package->price,
|
||||
'type' => 'endcustomer_event',
|
||||
'metadata' => json_encode(['note' => 'Free package assigned on event creation']),
|
||||
]);
|
||||
|
||||
if ($tenant->activeResellerPackage) {
|
||||
$tenant->incrementUsedEvents();
|
||||
}
|
||||
|
||||
return $event;
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant']);
|
||||
$event->load(['eventType', 'tenant', 'eventPackage.package']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
'data' => new EventResource($event),
|
||||
'balance' => $tenant->event_credits_balance,
|
||||
'package' => $event->eventPackage ? $event->eventPackage->package->name : 'None',
|
||||
'remaining_events' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->remaining_events : 0,
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
||||
36
app/Http/Controllers/Api/TenantPackageController.php
Normal file
36
app/Http/Controllers/Api/TenantPackageController.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class TenantPackageController extends Controller
|
||||
{
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (!$tenant) {
|
||||
return response()->json(['error' => 'Tenant not found.'], 404);
|
||||
}
|
||||
|
||||
$packages = TenantPackage::where('tenant_id', $tenant->id)
|
||||
->with('package')
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
$packages->each(function ($package) {
|
||||
$package->remaining_events = $package->package->max_events_per_year - $package->used_events;
|
||||
$package->package_limits = $package->package->getAttributes(); // Or custom accessor for limits
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'data' => $packages,
|
||||
'active_package' => $tenant->activeResellerPackage ? $tenant->activeResellerPackage->load('package') : null,
|
||||
'message' => 'Tenant packages loaded successfully.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -153,9 +153,9 @@ class OAuthController extends Controller
|
||||
'name' => $tenant->name,
|
||||
'slug' => $tenant->slug,
|
||||
'email' => $tenant->contact_email,
|
||||
'event_credits_balance' => $tenant->event_credits_balance,
|
||||
'subscription_tier' => $tenant->subscription_tier,
|
||||
'subscription_expires_at' => $tenant->subscription_expires_at,
|
||||
'active_reseller_package_id' => $tenant->active_reseller_package_id,
|
||||
'remaining_events' => $tenant->activeResellerPackage?->remaining_events ?? 0,
|
||||
'package_expires_at' => $tenant->activeResellerPackage?->expires_at,
|
||||
'features' => $tenant->features,
|
||||
'scopes' => Arr::get($decoded, 'scopes', []),
|
||||
]);
|
||||
|
||||
@@ -22,9 +22,9 @@ class CreditCheckMiddleware
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->requiresCredits($request) && $tenant->event_credits_balance < 1) {
|
||||
if ($this->requiresCredits($request) && !$tenant->canCreateEvent()) {
|
||||
return response()->json([
|
||||
'error' => 'Insufficient event credits. Please purchase more credits.',
|
||||
'error' => 'No available package for creating events. Please purchase a package.',
|
||||
], 402);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,4 +50,32 @@ class Event extends Model
|
||||
return $this->belongsToMany(Task::class, 'event_task', 'event_id', 'task_id')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
public function eventPackage(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventPackage::class);
|
||||
}
|
||||
|
||||
public function hasActivePackage(): bool
|
||||
{
|
||||
return $this->eventPackage && $this->eventPackage->isActive();
|
||||
}
|
||||
|
||||
public function getPackageLimits(): array
|
||||
{
|
||||
if (!$this->hasActivePackage()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->eventPackage->package->limits;
|
||||
}
|
||||
|
||||
public function canUploadPhoto(): bool
|
||||
{
|
||||
if (!$this->hasActivePackage()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->eventPackage->canUploadPhoto();
|
||||
}
|
||||
}
|
||||
|
||||
95
app/Models/EventPackage.php
Normal file
95
app/Models/EventPackage.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EventPackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'event_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'event_id',
|
||||
'package_id',
|
||||
'purchased_price',
|
||||
'purchased_at',
|
||||
'used_photos',
|
||||
'used_guests',
|
||||
'gallery_expires_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchased_price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'gallery_expires_at' => 'datetime',
|
||||
'used_photos' => 'integer',
|
||||
'used_guests' => 'integer',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->gallery_expires_at && $this->gallery_expires_at->isFuture();
|
||||
}
|
||||
|
||||
public function canUploadPhoto(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPhotos = $this->package->max_photos ?? 0;
|
||||
return $this->used_photos < $maxPhotos;
|
||||
}
|
||||
|
||||
public function canAddGuest(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxGuests = $this->package->max_guests ?? 0;
|
||||
return $this->used_guests < $maxGuests;
|
||||
}
|
||||
|
||||
public function getRemainingPhotosAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_photos ?? 0;
|
||||
return max(0, $this->max_photos - $this->used_photos);
|
||||
}
|
||||
|
||||
public function getRemainingGuestsAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_guests ?? 0;
|
||||
return max(0, $this->max_guests - $this->used_guests);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($eventPackage) {
|
||||
if (!$eventPackage->purchased_at) {
|
||||
$eventPackage->purchased_at = now();
|
||||
}
|
||||
if (!$eventPackage->gallery_expires_at && $eventPackage->package) {
|
||||
$days = $eventPackage->package->gallery_days ?? 30;
|
||||
$eventPackage->gallery_expires_at = now()->addDays($days);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
86
app/Models/Package.php
Normal file
86
app/Models/Package.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
|
||||
class Package extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'type',
|
||||
'price',
|
||||
'max_photos',
|
||||
'max_guests',
|
||||
'gallery_days',
|
||||
'max_tasks',
|
||||
'watermark_allowed',
|
||||
'branding_allowed',
|
||||
'max_events_per_year',
|
||||
'expires_after',
|
||||
'features',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'max_photos' => 'integer',
|
||||
'max_guests' => 'integer',
|
||||
'gallery_days' => 'integer',
|
||||
'max_tasks' => 'integer',
|
||||
'max_events_per_year' => 'integer',
|
||||
'expires_after' => 'datetime',
|
||||
'watermark_allowed' => 'boolean',
|
||||
'branding_allowed' => 'boolean',
|
||||
'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),
|
||||
);
|
||||
}
|
||||
|
||||
public function eventPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPackage::class);
|
||||
}
|
||||
|
||||
public function tenantPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TenantPackage::class);
|
||||
}
|
||||
|
||||
public function packagePurchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function isEndcustomer(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer';
|
||||
}
|
||||
|
||||
public function isReseller(): bool
|
||||
{
|
||||
return $this->type === 'reseller';
|
||||
}
|
||||
|
||||
public function getLimitsAttribute(): array
|
||||
{
|
||||
return [
|
||||
'max_photos' => $this->max_photos,
|
||||
'max_guests' => $this->max_guests,
|
||||
'gallery_days' => $this->gallery_days,
|
||||
'max_tasks' => $this->max_tasks,
|
||||
'max_events_per_year' => $this->max_events_per_year,
|
||||
];
|
||||
}
|
||||
}
|
||||
87
app/Models/PackagePurchase.php
Normal file
87
app/Models/PackagePurchase.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class PackagePurchase extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'package_purchases';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'event_id',
|
||||
'package_id',
|
||||
'provider_id',
|
||||
'price',
|
||||
'type',
|
||||
'metadata',
|
||||
'ip_address',
|
||||
'user_agent',
|
||||
'refunded',
|
||||
'purchased_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'metadata' => 'array',
|
||||
'refunded' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function event(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isEndcustomerEvent(): bool
|
||||
{
|
||||
return $this->type === 'endcustomer_event';
|
||||
}
|
||||
|
||||
public function isResellerSubscription(): bool
|
||||
{
|
||||
return $this->type === 'reseller_subscription';
|
||||
}
|
||||
|
||||
public function isRefunded(): bool
|
||||
{
|
||||
return $this->refunded;
|
||||
}
|
||||
|
||||
public function getMetadataAttribute($value)
|
||||
{
|
||||
return $value ? json_decode($value, true) : [];
|
||||
}
|
||||
|
||||
public function setMetadataAttribute($value)
|
||||
{
|
||||
$this->attributes['metadata'] = is_array($value) ? json_encode($value) : $value;
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($purchase) {
|
||||
if (!$purchase->purchased_at) {
|
||||
$purchase->purchased_at = now();
|
||||
}
|
||||
$purchase->refunded = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,6 @@ class Tenant extends Model
|
||||
'features' => 'array',
|
||||
'settings' => 'array',
|
||||
'last_activity_at' => 'datetime',
|
||||
'event_credits_balance' => 'integer',
|
||||
'subscription_tier' => 'string',
|
||||
'subscription_expires_at' => 'datetime',
|
||||
'total_revenue' => 'decimal:2',
|
||||
'settings_updated_at' => 'datetime',
|
||||
];
|
||||
@@ -46,17 +43,38 @@ class Tenant extends Model
|
||||
|
||||
public function purchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseHistory::class);
|
||||
return $this->hasMany(PackagePurchase::class);
|
||||
}
|
||||
|
||||
public function eventPurchases(): HasMany
|
||||
public function tenantPackages(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPurchase::class);
|
||||
return $this->hasMany(TenantPackage::class);
|
||||
}
|
||||
|
||||
public function creditsLedger(): HasMany
|
||||
public function activeResellerPackage()
|
||||
{
|
||||
return $this->hasMany(EventCreditsLedger::class);
|
||||
return $this->tenantPackages()->where('active', true)->first();
|
||||
}
|
||||
|
||||
public function canCreateEvent(): bool
|
||||
{
|
||||
$package = $this->activeResellerPackage();
|
||||
if (!$package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $package->canCreateEvent();
|
||||
}
|
||||
|
||||
public function incrementUsedEvents(int $amount = 1): bool
|
||||
{
|
||||
$package = $this->activeResellerPackage();
|
||||
if (!$package) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$package->increment('used_events', $amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setSettingsAttribute($value): void
|
||||
@@ -72,88 +90,7 @@ class Tenant extends Model
|
||||
public function activeSubscription(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn () => $this->subscription_expires_at && $this->subscription_expires_at->isFuture(),
|
||||
get: fn () => $this->activeResellerPackage() !== null,
|
||||
);
|
||||
}
|
||||
|
||||
public function decrementCredits(int $amount, string $reason = 'event_create', ?string $note = null, ?int $relatedPurchaseId = null): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
$locked = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked || $locked->event_credits_balance < $amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => -$amount,
|
||||
'reason' => $reason,
|
||||
'related_purchase_id' => $relatedPurchaseId,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$locked->event_credits_balance -= $amount;
|
||||
$locked->save();
|
||||
|
||||
$this->event_credits_balance = $locked->event_credits_balance;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return $this->runCreditOperation($operation);
|
||||
}
|
||||
|
||||
public function incrementCredits(int $amount, string $reason = 'manual_adjust', ?string $note = null, ?int $relatedPurchaseId = null): bool
|
||||
{
|
||||
if ($amount <= 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$operation = function () use ($amount, $reason, $note, $relatedPurchaseId) {
|
||||
$locked = static::query()
|
||||
->whereKey($this->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked) {
|
||||
return false;
|
||||
}
|
||||
|
||||
EventCreditsLedger::create([
|
||||
'tenant_id' => $this->id,
|
||||
'delta' => $amount,
|
||||
'reason' => $reason,
|
||||
'related_purchase_id' => $relatedPurchaseId,
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
$locked->event_credits_balance += $amount;
|
||||
$locked->save();
|
||||
|
||||
$this->event_credits_balance = $locked->event_credits_balance;
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
return $this->runCreditOperation($operation);
|
||||
}
|
||||
|
||||
private function runCreditOperation(callable $operation): bool
|
||||
{
|
||||
$connection = DB::connection();
|
||||
|
||||
if ($connection->transactionLevel() > 0) {
|
||||
return (bool) $operation();
|
||||
}
|
||||
|
||||
return (bool) $connection->transaction($operation);
|
||||
}
|
||||
}
|
||||
|
||||
93
app/Models/TenantPackage.php
Normal file
93
app/Models/TenantPackage.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class TenantPackage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'tenant_packages';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'package_id',
|
||||
'purchased_price',
|
||||
'purchased_at',
|
||||
'expires_at',
|
||||
'used_events',
|
||||
'active',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'purchased_price' => 'decimal:2',
|
||||
'purchased_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'used_events' => 'integer',
|
||||
'active' => 'boolean',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function package(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Package::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->active && (!$this->expires_at || $this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
public function canCreateEvent(): bool
|
||||
{
|
||||
if (!$this->isActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!$this->package->isReseller()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxEvents = $this->package->max_events_per_year ?? 0;
|
||||
return $this->used_events < $maxEvents;
|
||||
}
|
||||
|
||||
public function getRemainingEventsAttribute(): int
|
||||
{
|
||||
if (!$this->package->isReseller()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$max = $this->package->max_events_per_year ?? 0;
|
||||
return max(0, $max - $this->used_events);
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($tenantPackage) {
|
||||
if (!$tenantPackage->purchased_at) {
|
||||
$tenantPackage->purchased_at = now();
|
||||
}
|
||||
if (!$tenantPackage->expires_at && $tenantPackage->package) {
|
||||
$tenantPackage->expires_at = now()->addYear(); // Standard für Reseller
|
||||
}
|
||||
$tenantPackage->active = true;
|
||||
});
|
||||
|
||||
static::updating(function ($tenantPackage) {
|
||||
if ($tenantPackage->isDirty('expires_at') && $tenantPackage->expires_at->isPast()) {
|
||||
$tenantPackage->active = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user