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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@
|
||||
"simplesoftwareio/simple-qrcode": "^4.2",
|
||||
"spatie/laravel-translatable": "^6.11",
|
||||
"stephenjude/filament-blog": "*",
|
||||
"stripe/stripe-php": "^17.6"
|
||||
"stripe/stripe-php": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
2
composer.lock
generated
2
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a79b02d59d8ee7716beea2fc8442a905",
|
||||
"content-hash": "cb0adb8c2149ab0ab72bdc3b0b7ee635",
|
||||
"packages": [
|
||||
{
|
||||
"name": "anourvalar/eloquent-serialize",
|
||||
|
||||
@@ -14,6 +14,13 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'mailgun' => [
|
||||
'domain' => env('MAILGUN_DOMAIN'),
|
||||
'secret' => env('MAILGUN_SECRET'),
|
||||
'endpoint' => env('MAILGUN_ENDPOINT', 'api.mailgun.net'),
|
||||
'scheme' => 'https',
|
||||
],
|
||||
|
||||
'postmark' => [
|
||||
'token' => env('POSTMARK_TOKEN'),
|
||||
],
|
||||
@@ -24,26 +31,10 @@ return [
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
],
|
||||
|
||||
'resend' => [
|
||||
'key' => env('RESEND_KEY'),
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'notifications' => [
|
||||
'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'),
|
||||
'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'),
|
||||
],
|
||||
],
|
||||
|
||||
'stripe' => [
|
||||
'model' => App\Models\User::class,
|
||||
'key' => env('STRIPE_KEY'),
|
||||
'secret' => env('STRIPE_SECRET'),
|
||||
'webhook' => env('STRIPE_WEBHOOK_SECRET'),
|
||||
],
|
||||
|
||||
'revenuecat' => [
|
||||
'webhook' => env('REVENUECAT_WEBHOOK_SECRET'),
|
||||
'product_mappings' => env('REVENUECAT_PRODUCT_MAPPINGS', ''),
|
||||
'app_user_prefix' => env('REVENUECAT_APP_USER_PREFIX', 'tenant'),
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->enum('type', ['endcustomer', 'reseller']);
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->integer('max_photos')->nullable();
|
||||
$table->integer('max_guests')->nullable();
|
||||
$table->integer('gallery_days')->nullable();
|
||||
$table->integer('max_tasks')->nullable();
|
||||
$table->boolean('watermark_allowed')->default(true);
|
||||
$table->boolean('branding_allowed')->default(false);
|
||||
$table->integer('max_events_per_year')->nullable();
|
||||
$table->timestamp('expires_after')->nullable();
|
||||
$table->json('features')->nullable();
|
||||
$table->timestamps();
|
||||
$table->index(['type', 'price']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('packages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('event_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('purchased_price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->integer('used_photos')->default(0);
|
||||
$table->timestamps();
|
||||
$table->index('event_id');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('event_packages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tenant_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->timestamp('expires_at');
|
||||
$table->integer('used_events')->default(0);
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->index(['tenant_id', 'active']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tenant_packages');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('package_purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained();
|
||||
$table->foreignId('event_id')->nullable()->constrained();
|
||||
$table->foreignId('package_id')->constrained();
|
||||
$table->string('provider_id');
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->enum('type', ['endcustomer_event', 'reseller_subscription']);
|
||||
$table->json('metadata')->nullable();
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->boolean('refunded')->default(false);
|
||||
$table->timestamps();
|
||||
$table->index(['tenant_id', 'purchased_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('package_purchases');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('event_credits_ledger');
|
||||
Schema::dropIfExists('purchase_history');
|
||||
Schema::dropIfExists('event_purchases');
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'event_credits_balance',
|
||||
'subscription_tier',
|
||||
'subscription_expires_at',
|
||||
'free_event_granted_at',
|
||||
'total_revenue'
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->integer('event_credits_balance')->default(1);
|
||||
$table->string('subscription_tier')->nullable();
|
||||
$table->timestamp('subscription_expires_at')->nullable();
|
||||
$table->timestamp('free_event_granted_at')->nullable();
|
||||
$table->decimal('total_revenue', 10, 2)->default(0.00);
|
||||
});
|
||||
|
||||
Schema::create('event_purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->integer('credits_added')->default(0);
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->string('provider_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('purchase_history', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('package_id', 255);
|
||||
$table->integer('credits_added')->default(0);
|
||||
$table->decimal('price', 10, 2)->default(0);
|
||||
$table->string('provider_id');
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('event_credits_ledger', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->integer('credits_change');
|
||||
$table->string('reason');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Event;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Ensure packages table has data (seed if empty)
|
||||
if (DB::table('packages')->count() == 0) {
|
||||
// Insert standard packages if not seeded
|
||||
DB::table('packages')->insert([
|
||||
[
|
||||
'name' => 'Free/Test',
|
||||
'type' => 'endcustomer',
|
||||
'price' => 0.00,
|
||||
'max_photos' => 30,
|
||||
'max_guests' => 10,
|
||||
'gallery_days' => 3,
|
||||
'max_tasks' => 1,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => false,
|
||||
'max_events_per_year' => null,
|
||||
'expires_after' => null,
|
||||
'features' => json_encode([]),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'Starter',
|
||||
'type' => 'endcustomer',
|
||||
'price' => 19.00,
|
||||
'max_photos' => 300,
|
||||
'max_guests' => 50,
|
||||
'gallery_days' => 14,
|
||||
'max_tasks' => 5,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => false,
|
||||
'max_events_per_year' => null,
|
||||
'expires_after' => null,
|
||||
'features' => json_encode([]),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
// Add more standard packages as per plan
|
||||
[
|
||||
'name' => 'Reseller S',
|
||||
'type' => 'reseller',
|
||||
'price' => 149.00,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'max_events_per_year' => 5,
|
||||
'expires_after' => now()->addYear(),
|
||||
'features' => json_encode(['limited_branding']),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
// ... other reseller packages
|
||||
]);
|
||||
}
|
||||
|
||||
// Migrate tenant credits to tenant_packages (Free package)
|
||||
DB::table('tenants')->where('event_credits_balance', '>', 0)->chunk(100, function ($tenants) {
|
||||
foreach ($tenants as $tenant) {
|
||||
$freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id;
|
||||
DB::table('tenant_packages')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $freePackageId,
|
||||
'price' => 0.00,
|
||||
'purchased_at' => $tenant->free_event_granted_at ?? now(),
|
||||
'expires_at' => now()->addDays(30), // or based on credits
|
||||
'used_events' => min($tenant->event_credits_balance, 1), // e.g. 1 free event
|
||||
'active' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Create purchase ledger entry
|
||||
DB::table('package_purchases')->insert([
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => null,
|
||||
'package_id' => $freePackageId,
|
||||
'provider_id' => 'migration_free',
|
||||
'price' => 0.00,
|
||||
'type' => 'reseller_subscription',
|
||||
'metadata' => json_encode(['migrated_from_credits' => $tenant->event_credits_balance]),
|
||||
'ip_address' => null,
|
||||
'user_agent' => null,
|
||||
'refunded' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Migrate event purchases to event_packages (if any existing events)
|
||||
DB::table('events')->chunk(100, function ($events) {
|
||||
foreach ($events as $event) {
|
||||
if ($event->tenant->event_credits_balance > 0) { // or check if event was created with credits
|
||||
$freePackageId = DB::table('packages')->where('name', 'Free/Test')->first()->id;
|
||||
DB::table('event_packages')->insert([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $freePackageId,
|
||||
'purchased_price' => 0.00,
|
||||
'purchased_at' => $event->created_at,
|
||||
'used_photos' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
// Ledger entry
|
||||
DB::table('package_purchases')->insert([
|
||||
'tenant_id' => $event->tenant_id,
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $freePackageId,
|
||||
'provider_id' => 'migration_free',
|
||||
'price' => 0.00,
|
||||
'type' => 'endcustomer_event',
|
||||
'metadata' => json_encode(['migrated_from_credits' => true]),
|
||||
'ip_address' => null,
|
||||
'user_agent' => null,
|
||||
'refunded' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('packages', function (Blueprint $table) {
|
||||
//
|
||||
});
|
||||
}
|
||||
};
|
||||
138
database/seeders/PackageSeeder.php
Normal file
138
database/seeders/PackageSeeder.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
use App\Models\Package;
|
||||
|
||||
class PackageSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
// Endcustomer Packages
|
||||
Package::create([
|
||||
'name' => 'Free / Test',
|
||||
'type' => 'endcustomer',
|
||||
'price' => 0.00,
|
||||
'max_photos' => 30,
|
||||
'max_guests' => 10,
|
||||
'gallery_days' => 3,
|
||||
'max_tasks' => 5,
|
||||
'watermark_allowed' => false,
|
||||
'branding_allowed' => false,
|
||||
'features' => json_encode([
|
||||
'basic_uploads' => true,
|
||||
'limited_sharing' => true,
|
||||
'no_branding' => true,
|
||||
]),
|
||||
'description' => 'Ideal für kleine Test-Events oder erste Erfahrungen.',
|
||||
]);
|
||||
|
||||
Package::create([
|
||||
'name' => 'Starter',
|
||||
'type' => 'endcustomer',
|
||||
'price' => 19.00,
|
||||
'max_photos' => 300,
|
||||
'max_guests' => 50,
|
||||
'gallery_days' => 14,
|
||||
'max_tasks' => 20,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => false,
|
||||
'features' => json_encode([
|
||||
'extended_gallery' => true,
|
||||
'guest_sharing' => true,
|
||||
'basic_analytics' => true,
|
||||
]),
|
||||
'description' => 'Perfekt für kleine Events wie Geburtstage oder Firmenfeiern.',
|
||||
]);
|
||||
|
||||
Package::create([
|
||||
'name' => 'Pro',
|
||||
'type' => 'endcustomer',
|
||||
'price' => 49.00,
|
||||
'max_photos' => 1000,
|
||||
'max_guests' => 200,
|
||||
'gallery_days' => 30,
|
||||
'max_tasks' => 50,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'features' => json_encode([
|
||||
'unlimited_sharing' => true,
|
||||
'advanced_analytics' => true,
|
||||
'custom_branding' => true,
|
||||
'priority_support' => true,
|
||||
]),
|
||||
'description' => 'Für große Events wie Hochzeiten oder Konferenzen.',
|
||||
]);
|
||||
|
||||
// Reseller Packages (jährliche Subscriptions)
|
||||
Package::create([
|
||||
'name' => 'Reseller S',
|
||||
'type' => 'reseller',
|
||||
'price' => 149.00,
|
||||
'max_events_per_year' => 5,
|
||||
'max_photos' => null, // Kein globales Limit, pro Event
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'expires_after' => now()->addYear(), // Jährlich
|
||||
'features' => json_encode([
|
||||
'event_management' => true,
|
||||
'reseller_dashboard' => true,
|
||||
'bulk_event_creation' => true,
|
||||
'5_events_included' => true,
|
||||
]),
|
||||
'description' => 'Einstieg für kleine Agenturen: 5 Events pro Jahr.',
|
||||
]);
|
||||
|
||||
Package::create([
|
||||
'name' => 'Reseller M',
|
||||
'type' => 'reseller',
|
||||
'price' => 299.00,
|
||||
'max_events_per_year' => 15,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'expires_after' => now()->addYear(),
|
||||
'features' => json_encode([
|
||||
'event_management' => true,
|
||||
'reseller_dashboard' => true,
|
||||
'bulk_event_creation' => true,
|
||||
'advanced_reporting' => true,
|
||||
'15_events_included' => true,
|
||||
]),
|
||||
'description' => 'Für wachsende Agenturen: 15 Events pro Jahr.',
|
||||
]);
|
||||
|
||||
Package::create([
|
||||
'name' => 'Reseller L',
|
||||
'type' => 'reseller',
|
||||
'price' => 499.00,
|
||||
'max_events_per_year' => 30,
|
||||
'max_photos' => null,
|
||||
'max_guests' => null,
|
||||
'gallery_days' => null,
|
||||
'max_tasks' => null,
|
||||
'watermark_allowed' => true,
|
||||
'branding_allowed' => true,
|
||||
'expires_after' => now()->addYear(),
|
||||
'features' => json_encode([
|
||||
'event_management' => true,
|
||||
'reseller_dashboard' => true,
|
||||
'bulk_event_creation' => true,
|
||||
'priority_support' => true,
|
||||
'custom_integration' => true,
|
||||
'30_events_included' => true,
|
||||
]),
|
||||
'description' => 'Für große Agenturen: 30 Events pro Jahr mit Premium-Features.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
241
docs/packages-business-model-plan.md
Normal file
241
docs/packages-business-model-plan.md
Normal file
@@ -0,0 +1,241 @@
|
||||
# Fotospiel: Umstellung auf Package-basiertes Business Model – Detaillierter Plan
|
||||
|
||||
**Datum:** 2025-09-26
|
||||
**Version:** 1.0
|
||||
**Autor:** Kilo Code (Architect Mode)
|
||||
**Status:** Finaler Plan für Review und Implementation in Code-Mode.
|
||||
**Ziel:** Ersetze das aktuelle Credits-basierte Freemium-Modell (One-off-Käufe via Stripe/RevenueCat, Balance-Checks) durch ein package-basiertes Modell mit vordefinierten Bündeln (Einmalkäufe pro Event für Endkunden, jährliche Subscriptions für Reseller/Agenturen). Der Plan deckt Analyse, Design, Änderungen in DB/Code/UI/Billing, Lücken und Rollout ab. Alle Details basieren auf User-Feedback und Best Practices für Laravel 12, Filament 4, React/Vite PWA.
|
||||
|
||||
## 1. Analyse des Aktuellen Modells
|
||||
Das bestehende Modell ist Credits-basiert (Freemium mit 1 Free-Credit, One-off-Käufen für Events). Subscriptions sind deferred (nicht implementiert).
|
||||
|
||||
### Betroffene Komponenten:
|
||||
- **DB:**
|
||||
- Felder: `event_credits_balance` (in `tenants`, default 1), `subscription_tier`/`subscription_expires_at` (in `tenants`).
|
||||
- Tabellen: `event_purchases` (Käufe), `event_credits_ledger` (Transaktionen), `purchase_history` (IAP-Historie).
|
||||
- **Code (Backend):**
|
||||
- Models: `Tenant::decrementCredits()`/`incrementCredits()`.
|
||||
- Controllers: `EventController` (Credit-Check bei Create), `CreditController` (Balance/Purchase).
|
||||
- Middleware: `CreditMiddleware` (prüft Balance >=1 für Events).
|
||||
- Filament: `TenantResource` (credits-Column, add_credits-Action), `PurchaseHistoryResource` (CRUD/Refund).
|
||||
- **API:** Endpunkte `/api/v1/tenant/credits/balance`, `/credits/ledger`, `/credits/purchase`, `/credits/sync`, `/purchases/intent`.
|
||||
- **Frontend (Admin PWA):** Dashboard-Cards für Balance, Kauf-Integration (RevenueCat).
|
||||
- **Guest PWA:** Keine direkten Checks (Backend-handhabt).
|
||||
- **Billing:** Stripe (Checkout/Webhooks), RevenueCat (IAP), PayPalWebhookController (teilweise).
|
||||
- **Tests:** `RevenueCatWebhookTest`, Credit-Unit-Tests.
|
||||
- **Docs:** PRP 08-billing.md (Credits-MVP), 14-freemium-business-model.md (IAP-Struktur), API-Specs (credits-Endpunkte).
|
||||
- **Lücken im Aktuellen:** Keine Package-Limits (nur Balance), Subscriptions nicht live, PayPal untergenutzt.
|
||||
|
||||
**Auswirkungen:** Vollständige Ersetzung, um Flexibilität (Limits/Features pro Package) zu ermöglichen.
|
||||
|
||||
## 2. Neues Package-basiertes Modell
|
||||
Packages ersetzen Credits: Vordefinierte Bündel mit Limits/Features. Kauf bei Event-Create (Endkunden) oder Tenant-Upgrade (Reseller). Freemium: Free/Test-Paket für Einstieg.
|
||||
|
||||
### Endkunden-Pakete (Einmalkäufe pro Event)
|
||||
| Paket | Preis | max_photos | max_guests | gallery_days | max_tasks | watermark | branding | Features |
|
||||
|-----------|-------|------------|------------|--------------|-----------|-----------|----------|----------|
|
||||
| Free/Test | 0 € | 30 | 10 | 3 | 1 | Standard | Nein | - |
|
||||
| Starter | 19 € | 300 | 50 | 14 | 5 | Standard | Nein | - |
|
||||
| Standard | 39 € | 1000 | 150 | 30 | 10 | Custom | Ja | Logo |
|
||||
| Premium | 79 € | 3000 | 500 | 180 | 20 | Kein | Ja | Live-Slideshow, Analytics |
|
||||
|
||||
### Reseller/Agentur-Pakete (Jährliche Subscriptions)
|
||||
| Paket | Preis/Jahr | max_events/year | Per-Event Limits | Branding | Extra |
|
||||
|------------|------------|-----------------|------------------|----------|-------|
|
||||
| Reseller S | 149 € | 5 | Standard | Eingeschränkt | - |
|
||||
| Reseller M | 299 € | 15 | Standard | Eigene Logos | 3 Monate Galerie |
|
||||
| Reseller L | 599 € | 40 | Premium | White-Label | - |
|
||||
| Enterprise | ab 999 € | Unlimited | Premium | Voll | Custom Domain, Support |
|
||||
|
||||
**Flow:** Event-Create: Package wählen → Kauf (Free: direkt; Paid: Checkout) → Limits für Event setzen. Reseller: Tenant-Package limitiert Events/Features global.
|
||||
|
||||
## 3. DB-Schema & Migrationen
|
||||
### Neue Tabellen (Migration: create_packages_tables.php)
|
||||
- **packages (global):**
|
||||
```php
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->enum('type', ['endcustomer', 'reseller']);
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->integer('max_photos')->nullable();
|
||||
$table->integer('max_guests')->nullable();
|
||||
$table->integer('gallery_days')->nullable();
|
||||
$table->integer('max_tasks')->nullable();
|
||||
$table->boolean('watermark_allowed')->default(true);
|
||||
$table->boolean('branding_allowed')->default(false);
|
||||
$table->integer('max_events_per_year')->nullable();
|
||||
$table->timestamp('expires_after')->nullable(); // Für Subscriptions
|
||||
$table->json('features')->nullable(); // ['live_slideshow', 'analytics']
|
||||
$table->timestamps();
|
||||
$table->index(['type', 'price']); // Für Queries
|
||||
```
|
||||
- **event_packages (pro Event):**
|
||||
```php
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('purchased_price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->integer('used_photos')->default(0); // Counter
|
||||
$table->timestamps();
|
||||
$table->index('event_id');
|
||||
```
|
||||
- **tenant_packages (Reseller):**
|
||||
```php
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->timestamp('expires_at');
|
||||
$table->integer('used_events')->default(0);
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
$table->index(['tenant_id', 'active']);
|
||||
```
|
||||
- **package_purchases (Ledger):**
|
||||
```php
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->nullable()->constrained();
|
||||
$table->foreignId('event_id')->nullable()->constrained();
|
||||
$table->foreignId('package_id')->constrained();
|
||||
$table->string('provider_id'); // Stripe/PayPal ID
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->enum('type', ['endcustomer_event', 'reseller_subscription']);
|
||||
$table->json('metadata'); // {event_id, ip_address}
|
||||
$table->string('ip_address')->nullable();
|
||||
$table->string('user_agent')->nullable();
|
||||
$table->boolean('refunded')->default(false);
|
||||
$table->timestamps();
|
||||
$table->index(['tenant_id', 'purchased_at']);
|
||||
```
|
||||
|
||||
### Migration-Strategie (php artisan make:migration migrate_to_packages)
|
||||
- **Schritt 1:** Neue Tabellen erstellen + Seeder für Standard-Packages (php artisan make:seeder PackageSeeder).
|
||||
- **Schritt 2:** Daten-Transfer (Artisan-Command packages:migrate):
|
||||
- Tenants: if event_credits_balance > 0 → Zuweisen zu Free-Paket (insert tenant_packages mit expires_at = now() + 30 days); alte Balance zu used_events konvertieren (z.B. balance / 100 = initial events).
|
||||
- Events: Bestehende Events zu Test-Paket migrieren (insert event_packages).
|
||||
- Ledger: Transfer event_purchases zu package_purchases (map credits_added zu package_id = 'free').
|
||||
- **Schritt 3:** Alte Felder/Tabellen droppen (in separater Migration, nach Backup).
|
||||
- **Rollback:** php artisan migrate:rollback --step=3; Restore aus Backup.
|
||||
- **Performance:** Transactions für Migration; Cache::flush() nach.
|
||||
|
||||
## 4. Filament 4 Resources (Backend-Logik, Todo 6)
|
||||
- **PackageResource (app/Filament/Resources/PackageResource.php, SuperAdmin):**
|
||||
- Form: TextInput('name'), Select('type'), MoneyInput('price'), NumericInputs für Limits, Toggles für watermark/branding, Repeater('features'), Numeric('max_events_per_year').
|
||||
- Table: TextColumn('name'), BadgeColumn('type'), MoneyColumn('price'), IconColumn('limits' – z.B. CameraIcon für max_photos), Actions (Edit/Delete/Duplicate).
|
||||
- Pages: ListPackages, CreatePackage, EditPackage.
|
||||
- Policy: SuperAdmin only.
|
||||
|
||||
- **TenantPackageResource (SuperAdmin/TenantAdmin):**
|
||||
- Form: Select('tenant_id'), Select('package_id'), DateTimePicker('purchased_at'), DateTimePicker('expires_at'), TextInput('used_events', readOnly), Toggle('active').
|
||||
- Table: TextColumn('tenant.name'), BadgeColumn('package.name'), DateColumn('expires_at', color: expired → danger), ProgressColumn('used_events' / max_events), Actions (Renew: set expires_at +1 year, Cancel: active=false + Stripe/PayPal cancel).
|
||||
- Relations: BelongsTo Tenant/Package, HasMany Events (RelationManager mit Event-List).
|
||||
- Bulk-Actions: Renew Selected.
|
||||
|
||||
- **PurchaseResource (SuperAdmin/TenantAdmin):**
|
||||
- Form: Select('tenant_id/event_id'), Select('package_id'), TextInput('provider_id'), MoneyInput('price'), Select('type'), JSONEditor('metadata'), Toggle('refunded').
|
||||
- Table: BadgeColumn('type'), LinkColumn('tenant' or 'event'), TextColumn('package.name/price'), DateColumn('purchased_at'), BadgeColumn('status' – paid/refunded), Actions (View, Refund: Call Stripe/PayPal API, decrement counters, log).
|
||||
- Filters: SelectFilter('type'), DateRangeFilter('purchased_at'), TenantFilter.
|
||||
- Widgets: StatsOverview (Total Revenue, Monthly Purchases, Top Package), ChartWidget (Revenue over Time via Laravel Charts).
|
||||
- Export: CSV (für Buchhaltung: tenant, package, price, date).
|
||||
|
||||
**Integration:** Ersetze add_credits in TenantResource durch 'Assign Package'-Action (modal mit Select + Intent-Call). Policies: Role-based (superadmin full, tenant_admin own).
|
||||
|
||||
## 5. Marketing- und Legal-Anpassungen (Todo 4)
|
||||
- **Webfrontend (Blade, resources/views/marketing/):**
|
||||
- **packages.blade.php (neu, Route /packages):** Hero ("Entdecken Sie unsere Packages"), Tabs (Endkunden/Reseller), Tabelle/Accordion mit Details (Preis, Limits als Icons, Features-Bullets, i18n-Übersetzungen). CTA: "Kaufen" → /checkout/{id}. Dynamisch: @foreach(Package::where('type', 'endcustomer')->get() as $package).
|
||||
- **checkout.blade.php (neu, Route /checkout/{package_id}):** Summary-Box (Package-Details), Form (Name, E-Mail, Adresse für Reseller), Zahlungsoptionen (Radio: Stripe/PayPal), Stripe-Element/PayPal-Button. Submit: POST /purchases/intent → Redirect. Tailwind: Secure-Design mit Badges.
|
||||
- **success.blade.php:** "Vielen Dank! Package {name} gekauft." Details (Limits, Event-Link), Upsell ("Upgrade zu Reseller?"), Rechnung-Download (PDF via Dompdf), Onboarding-Tour-Link.
|
||||
- **marketing.blade.php:** Teaser-Section mit Package-Icons/Preisen, Link zu /packages.
|
||||
- **occasions.blade.php/blog*.blade.php:** Kontextuelle Erwähnungen (z.B. "Ideal für Partys: Starter-Paket"), Blog-Post "Neues Package-Modell" mit FAQ.
|
||||
|
||||
- **Legal (resources/views/legal/):**
|
||||
- **datenschutz.blade.php:** Abschnitt "Zahlungen" (Stripe/PayPal: Keine Karten-Speicherung, GDPR: Löschung nach 10 Jahren; Consent für E-Mails). "Package-Daten (Limits) sind anonymisiert."
|
||||
- **impressum.blade.php:** "Monetarisierung: Packages via Stripe/PayPal; USt-ID: ...; Support: support@fotospiel.de".
|
||||
- **Allgemein:** Datum "Aktualisiert: 2025-09-26 – Package-Modell"; Links zu Provider-Datenschutz.
|
||||
|
||||
**i18n:** Translations in lang/de/en (z.B. 'package.starter' → 'Starter-Paket').
|
||||
|
||||
## 6. Backend-Logik & API (Todo 6/7)
|
||||
- **Controllers:**
|
||||
- `PackagesController` (index: Liste mit Cache, show: Details, store: Intent für Kauf).
|
||||
- `PurchasesController` (intent: Erstelle Stripe-Session oder PayPal-Order basierend auf method; store: Nach Webhook).
|
||||
- **Middleware:** `PackageMiddleware` (für Events: Check event_packages.used_photos < max_photos; für Tenant: used_events < max_events_per_year).
|
||||
- **Models:** `Package` (Relationships: hasMany EventPackage/TenantPackage), `EventPackage` (incrementUsedPhotos-Method), `TenantPackage` (isActive-Scope, Observer für Expiry: E-Mail + active=false).
|
||||
- **API-Endpunkte (routes/api.php, tenant-group):**
|
||||
- GET /packages (Liste, filter by type).
|
||||
- GET /packages/{id} (Details).
|
||||
- POST /packages/purchase (Body: package_id, type, event_id?; Response: {checkout_url, provider}).
|
||||
- GET /tenant/packages (Active Package, Purchases-List).
|
||||
- POST /tenant/packages/assign (Free-Zuweisung).
|
||||
- DELETE /credits/* (entfernen, 404-redirect).
|
||||
- Tokens: Füge 'package_info' (JSON: active_package_id) zu JWT-Claims hinzu (via Sanctum).
|
||||
- **Jobs:** `ProcessPackagePurchase` (nach Webhook: Zuweisen, E-Mail, Analytics-Event).
|
||||
|
||||
## 7. Frontend-Anpassungen (Todo 8/9)
|
||||
- **Admin PWA (resources/js/admin/):**
|
||||
- EventFormPage.tsx: Select('package_id') mit Details-Modal (Limits/Preis), Button 'Kaufen' → Stripe/PayPal-Integration (stripe.elements oder PayPal-Button).
|
||||
- Dashboard: Card 'Aktuelles Package' (Limits, Expiry, Upgrade-Button).
|
||||
- SettingsPage.tsx: Reseller-Übersicht (used_events/Progress, Renew-Button).
|
||||
- Hooks: usePackageLimits (fetch /packages, check used_photos).
|
||||
- **Guest PWA (resources/js/guest/):**
|
||||
- EventDetailPage.tsx: Header "Package: Premium – {used_photos}/{max_photos} Fotos, Galerie bis {date}".
|
||||
- Upload-Component: If used_photos >= max_photos → Disable + Message "Limit erreicht – Upgrade via Admin".
|
||||
- Features: Watermark-Overlay if watermark_allowed; Branding-Logo if branding_allowed.
|
||||
- Router: Guard für Limits (z.B. /upload → Check API).
|
||||
|
||||
**Tech:** React Query für API-Calls, Stripe.js/PayPal-SDK in Components, i18n mit react-i18next.
|
||||
|
||||
## 8. Billing-Integration (Todo 10)
|
||||
- **Provider:** Stripe (Primär: Einmalkäufe/Subscriptions) + PayPal (Alternative: PHP SDK für Orders/Subscriptions).
|
||||
- **Flow:** Auswahl → Intent (Controller: if 'stripe' → Stripe::checkout()->sessions->create([...]); if 'paypal' → PayPal::orders()->create([...]) ) → Redirect → Webhook (verifiziert, insert package_purchases, assign Package, E-Mail).
|
||||
- **Webhooks:** StripeWebhookController (neue Events: checkout.session.completed → ProcessPurchase), PayPalWebhookController (erweitert: PAYMENT.CAPTURE.COMPLETED → ProcessPurchase).
|
||||
- **SDKs:** composer require stripe/stripe-php ^10.0, paypal/rest-api-sdk-php ^1.14; NPM: @stripe/stripe-js, @paypal/react-paypal-js.
|
||||
- **Free:** Kein Provider – direkt assign via API.
|
||||
- **Refunds:** Action in PurchaseResource: Call Stripe::refunds->create oder PayPal::refunds, decrement Counters.
|
||||
- **Env:** STRIPE_KEY/SECRET, PAYPAL_CLIENT_ID/SECRET, SANDBOX-Flags.
|
||||
|
||||
## 9. Tests (Todo 11)
|
||||
- **Unit/Feature:** Pest/PHPUnit: Test PackageSeeder, Migration (assert Tables exist), Controllers (mock Stripe/PayPal SDKs mit Stripe::mock(), test Intent/Webhook), Models (Package::find(1)->limits, TenantPackage::isActive), Middleware (assert denies if limit exceeded).
|
||||
- **E2E (Playwright):** Test Kauf-Flow (navigate /packages, select Starter, choose PayPal, complete sandbox, assert success.blade.php), Limits (upload photo, assert counter +1, deny at max).
|
||||
- **Anpassungen:** RevenueCatWebhookTest → Stripe/PayPalWebhookTest; Add PackageValidationTest (e.g. EventCreate without Package → 422).
|
||||
- **Coverage:** 80% für Billing/DB; Mock Providers für Isolation.
|
||||
|
||||
## 10. Deployment & Rollout (Todo 12)
|
||||
- **Vorbereitung:** Backup DB (php artisan db:backup), Staging-Env (duplicate prod, test Migration).
|
||||
- **Schritte:**
|
||||
1. Deploy Migration/Seeder (php artisan migrate, db:seed --class=PackageSeeder).
|
||||
2. Run packages:migrate (Command: Transfer Daten, log Errors).
|
||||
3. Update Code (Controllers/Middleware/Resources, API-Routes).
|
||||
4. Frontend-Build (npm run build for PWAs).
|
||||
5. Smoke-Tests (Kauf-Flow, Limits, Webhooks mit Sandbox).
|
||||
6. Go-Live: Feature-Flag (config/packages.enabled = true), Monitor mit Telescope/Sentry.
|
||||
- **Rollback:** migrate:rollback, restore Backup.
|
||||
- **Post-Deployment:** Update TODO.md (neue Tasks: Monitor Conversions), Gogs-Issues (z.B. "Implement Package Analytics"), E-Mail an Users ("Neues Package-Modell – Ihr Free-Paket ist aktiv").
|
||||
- **Monitoring:** Scheduled Job (daily: Check expired Packages, notify), Revenue-Dashboard in Filament.
|
||||
|
||||
## 11. Identifizierte Lücken & Best Practices
|
||||
- **Sicherheit:** PCI-Compliance (Provider-handhabt), Audit-Logs (payments-channel), Rate-Limiting (/checkout: 5/min), GDPR (Lösch-Job, Consent in Checkout).
|
||||
- **i18n:** Package-Features als translatable JSON, Locale in Checkout (Stripe metadata).
|
||||
- **Analytics:** GA-Events in Frontend, Telescope für Backend-Käufe, ARPU-Tracking in Widgets.
|
||||
- **Support:** E-Mail-Templates (PurchaseMailable), FAQ in /support/packages, Onboarding-Tour post-Kauf.
|
||||
- **Performance:** Caching (Packages-Liste), Indexing (purchased_at), Queues für Webhooks (ProcessPurchaseJob).
|
||||
- **Edge-Cases:** Upgrade (prorate Preis, transfer Limits), Expiry (Observer + E-Mail), Offline-PWA (queued Käufe sync).
|
||||
- **Dependencies:** Stripe/PayPal SDKs, Dompdf (Rechnungen), Laravel Cashier (optional für Stripe).
|
||||
- **Kosten:** Env für Sandbox/Prod-Keys; Test mit Stripe/PayPal Test-Accounts.
|
||||
|
||||
## 12. Todo-List (Status: Alle Planung completed)
|
||||
- [x] Analyse.
|
||||
- [x] Design (15-packages-design.md).
|
||||
- [x] PRP-Updates.
|
||||
- [x] Marketing/Legal (Blades mit Checkout).
|
||||
- [x] DB-Migrationen.
|
||||
- [ ] Backend (Resources/Controllers).
|
||||
- [ ] API.
|
||||
- [ ] PWAs.
|
||||
- [ ] Billing (SDKs/Webhooks).
|
||||
- [ ] Tests.
|
||||
- [ ] Deployment.
|
||||
|
||||
**Nächster Schritt:** Wechsel zu Code-Mode für Implementation (start with DB-Migrationen). Kontaktieren Sie für Änderungen.
|
||||
@@ -1,7 +1,7 @@
|
||||
# 08 — Billing (MVP: Event Credits)
|
||||
# 08 — Billing (Packages)
|
||||
|
||||
- Model: one-off purchases that grant event credits; no subscriptions in MVP.
|
||||
- Tables: `event_purchases`, `event_credits_ledger` (see 04-data-model-migrations.md).
|
||||
- Providers: Stripe (server-side checkout + webhooks); store receipts deferred.
|
||||
- Idempotency: purchase intents keyed; ledger writes idempotent; retries safe.
|
||||
- Limits: enforce `event_credits_balance >= 1` to create an event; ledger decrements on event creation.
|
||||
- Model: one-off purchases of event packages (Endkunden) or annual subscriptions (Reseller); see 15-packages-design.md for details.
|
||||
- Tables: `packages`, `event_packages`, `tenant_packages`, `package_purchases` (see 04-data-model-migrations.md and 15-packages-design.md).
|
||||
- Providers: Stripe (server-side checkout + webhooks for Einmalkäufe/Subscriptions); store receipts.
|
||||
- Idempotency: purchase intents keyed; purchase writes idempotent; retries safe.
|
||||
- Limits: Enforce package selection at event creation; check event-specific limits (e.g. max_photos) during usage; tenant limits for reseller event count.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This document details the Freemium business model for the Fotospiel tenant app, combining free access with in-app purchases for event credits. The model prioritizes user acquisition through a free app download while monetizing through value-driven upgrades. Key metrics: 5-10% conversion rate, ARPU €10-15, scalable to 100k+ users.
|
||||
This document details the Package-based business model for the Fotospiel tenant app, combining free access with purchases of predefined packages. The model prioritizes user acquisition through a free test package while monetizing through value-driven upgrades. Key metrics: 5-10% conversion rate, ARPU €10-15, scalable to 100k+ users. See 15-packages-design.md for package details.
|
||||
|
||||
## Model Analysis
|
||||
|
||||
@@ -30,18 +30,13 @@ This document details the Freemium business model for the Fotospiel tenant app,
|
||||
- Complex IAP setup and testing
|
||||
- Requires strong onboarding to drive conversions
|
||||
|
||||
### Hybrid Freemium Recommendation
|
||||
**Core Strategy:** Free app with limited first event (50 photos, basic features), unlimited upgrades via IAP credits/subscriptions.
|
||||
### Package-based Recommendation
|
||||
**Core Strategy:** Free app with limited test package for first event, upgrades via package purchases (Einmalkäufe for Endkunden, Subscriptions for Reseller).
|
||||
|
||||
**Pricing Structure:**
|
||||
- **Free Tier:** 1 basic event (50 photos, standard tasks, no custom branding)
|
||||
- **Consumable Credits:**
|
||||
- Starter Pack: €4.99 for 5 events (100 photos each)
|
||||
- Pro Pack: €14.99 for 20 events (unlimited photos)
|
||||
- **Subscriptions:**
|
||||
- Pro Unlimited: €4.99/month (all features, unlimited events)
|
||||
- Agency: €19.99/month (multi-tenant, analytics, white-label)
|
||||
- **Non-Consumables:** Lifetime Unlimited: €49.99 (one-time purchase)
|
||||
**Pricing Structure:** See 15-packages-design.md for Endkunden (pro Event: Free/Test 0€, Starter 19€, etc.) and Reseller (jährlich: S 149€, etc.).
|
||||
- **Free Tier:** Test package (30 photos, 10 guests, 3 days gallery, 1 task, standard watermark)
|
||||
- **Endkunden Packages:** Einmalkäufe pro Event with increasing limits/features.
|
||||
- **Reseller Packages:** Annual subscriptions with event limits and branding options.
|
||||
|
||||
**Expected Metrics:**
|
||||
- Downloads: 50k/year
|
||||
@@ -91,38 +86,13 @@ This document details the Freemium business model for the Fotospiel tenant app,
|
||||
- **Analytics:** Firebase for funnel tracking, RevenueCat for purchase events
|
||||
|
||||
### Backend API Extensions
|
||||
- **Credit Management:** `/api/v1/tenant/credits` endpoints
|
||||
- **Purchase Validation:** Webhook receiver from RevenueCat
|
||||
- **Package Management:** `/api/v1/packages` and `/api/v1/tenant/packages` endpoints
|
||||
- **Purchase Validation:** Webhook receiver from Stripe
|
||||
- **Event Limiting:** Middleware checking credit balance before creation
|
||||
- **Subscription Sync:** Real-time updates via WebSockets (optional)
|
||||
|
||||
### Database Schema Additions
|
||||
```sql
|
||||
-- tenant_credits table
|
||||
CREATE TABLE tenant_credits (
|
||||
tenant_id VARCHAR(255) PRIMARY KEY,
|
||||
balance INTEGER DEFAULT 1, -- 1 free event
|
||||
total_purchased INTEGER DEFAULT 0,
|
||||
subscription_active BOOLEAN DEFAULT FALSE,
|
||||
subscription_tier VARCHAR(50),
|
||||
last_sync TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- purchase_history table
|
||||
CREATE TABLE purchase_history (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
tenant_id VARCHAR(255) NOT NULL,
|
||||
package_id VARCHAR(255) NOT NULL,
|
||||
credits_added INTEGER,
|
||||
price DECIMAL(10,2),
|
||||
currency VARCHAR(3),
|
||||
platform VARCHAR(50), -- 'ios' or 'android'
|
||||
transaction_id VARCHAR(255),
|
||||
purchased_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (tenant_id) REFERENCES tenants(id)
|
||||
);
|
||||
```
|
||||
See 15-packages-design.md for updated schema: `packages`, `event_packages`, `tenant_packages`, `package_purchases`.
|
||||
|
||||
## Risk Mitigation
|
||||
|
||||
|
||||
115
docs/prp/15-packages-design.md
Normal file
115
docs/prp/15-packages-design.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# Packages-Design für Fotospiel
|
||||
|
||||
## Überblick
|
||||
Dieses Dokument definiert das neue package-basierte Business Model, das das bestehende Credits-System ersetzt. Packages sind vordefinierte Bündel mit Limits und Features, die als Einmalkäufe pro Event (für Endkunden) oder jährliche Subscriptions (für Reseller/Agenturen) verkauft werden. Das Modell priorisiert Einfachheit: Bei Event-Erstellung wählt der User ein Package, das Limits für diesen Event setzt. Für Reseller limitiert das Tenant-Package die Anzahl Events pro Jahr und globale Features.
|
||||
|
||||
Ziele:
|
||||
- Ersetze Credits vollständig (keine Balance mehr, sondern Event-spezifische Limits).
|
||||
- Unterstütze Freemium: Free-Paket für Einstieg.
|
||||
- Skalierbar: Endkunden pro Event, Reseller jährlich.
|
||||
- Integration: Stripe für Zahlungen (Einmalkäufe/Subscriptions), Ledger für Transaktionen.
|
||||
|
||||
## Endkunden-Pakete (pro Event, Einmalkauf)
|
||||
Diese Pakete werden bei Event-Erstellung ausgewählt und gekauft. Sie definieren Limits für den spezifischen Event (z.B. max_photos, gallery_duration). Preise basierend auf User-Vorschlag.
|
||||
|
||||
| Paket | Preis | max_photos | max_guests | gallery_days | max_tasks | watermark | branding | Extra Features |
|
||||
|-----------|-------|------------|------------|--------------|-----------|-----------|----------|----------------|
|
||||
| Free/Test | 0 € | 30 | 10 | 3 | 1 | Standard | Nein | - |
|
||||
| Starter | 19 € | 300 | 50 | 14 | 5 | Standard | Nein | - |
|
||||
| Standard | 39 € | 1000 | 150 | 30 | 10 | Custom | Ja | Logo |
|
||||
| Premium | 79 € | 3000 | 500 | 180 | 20 | Kein | Ja | Live-Slideshow, Analytics |
|
||||
|
||||
## Reseller/Agentur-Pakete (jährlich, Subscription)
|
||||
Diese Pakete werden auf Tenant-Ebene gekauft und limitieren Events pro Jahr, mit erweiterten Features (z.B. White-Label). Preise als Richtwerte.
|
||||
|
||||
| Paket | Preis/Jahr | max_events/year | Per-Event Limits | Branding | Extra |
|
||||
|------------|------------|-----------------|------------------|----------|-------|
|
||||
| Reseller S | 149 € | 5 | Standard | Eingeschränkt | - |
|
||||
| Reseller M | 299 € | 15 | Standard | Eigene Logos | 3 Monate Galerie |
|
||||
| Reseller L | 599 € | 40 | Premium | White-Label | - |
|
||||
| Enterprise | ab 999 € | Unlimited | Premium | Voll | Custom Domain, Support |
|
||||
|
||||
## Datenbank-Schema
|
||||
### Globale Packages-Tabelle (für alle Pakete, geteilt)
|
||||
```php
|
||||
Schema::create('packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name'); // z.B. 'Starter', 'Reseller M'
|
||||
$table->string('type'); // 'endcustomer' oder 'reseller'
|
||||
$table->decimal('price', 8, 2); // Preis in EUR
|
||||
$table->integer('max_photos')->nullable(); // Null für Reseller (vererbt an Events)
|
||||
$table->integer('max_guests')->nullable();
|
||||
$table->integer('gallery_days')->nullable();
|
||||
$table->integer('max_tasks')->nullable();
|
||||
$table->boolean('watermark_allowed')->default(true);
|
||||
$table->boolean('branding_allowed')->default(false);
|
||||
$table->integer('max_events_per_year')->nullable(); // Für Reseller
|
||||
$table->timestamp('expires_after')->nullable(); // Für Subscriptions
|
||||
$table->json('features')->nullable(); // z.B. ['live_slideshow', 'analytics']
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
Seeder: Füge die obigen Pakete ein.
|
||||
|
||||
### Event-Packages (Zuordnung Event zu Endkunden-Paket)
|
||||
```php
|
||||
Schema::create('event_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('purchased_price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->integer('used_photos')->default(0); // Counter für Limits
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
- Bei Event-Create: Package auswählen/kaufen, Eintrag erstellen.
|
||||
- Checks: z.B. if ($event->package->used_photos >= $event->package->max_photos) abort(403);
|
||||
|
||||
### Tenant-Packages (für Reseller)
|
||||
```php
|
||||
Schema::create('tenant_packages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('package_id')->constrained()->cascadeOnDelete();
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->timestamp('purchased_at');
|
||||
$table->timestamp('expires_at'); // z.B. +1 Jahr
|
||||
$table->integer('used_events')->default(0); // Counter für max_events_per_year
|
||||
$table->boolean('active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
- Bei Tenant-Registrierung: Free-Reseller oder Upgrade.
|
||||
- Check: if ($tenant->activePackage->used_events >= $tenant->activePackage->max_events_per_year) block new Event.
|
||||
|
||||
### Ledger und Purchases (ersetzt Credits-Ledger)
|
||||
```php
|
||||
Schema::create('package_purchases', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->nullable(); // Null für Event-spezifisch
|
||||
$table->foreignId('event_id')->constrained()->nullable(); // Für Endkunden
|
||||
$table->foreignId('package_id')->constrained();
|
||||
$table->string('stripe_id'); // Für Webhook/Idempotenz
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->string('type'); // 'endcustomer_event', 'reseller_subscription'
|
||||
$table->json('metadata'); // z.B. {'event_id': 123}
|
||||
$table->timestamps();
|
||||
});
|
||||
```
|
||||
- Kein separater Ledger nötig; Purchases tracken alles.
|
||||
|
||||
## Integration und Logik
|
||||
- **Event-Create Flow**: User wählt Package → Stripe-Checkout (Einmalkauf) → Webhook bestätigt → Event mit package_id erstellen.
|
||||
- **Reseller-Upgrade**: Im Admin-Dashboard Package auswählen → Subscription erstellen → expires_at setzen.
|
||||
- **Limits-Checks**: Middleware prüft Event-Package-Limits (Photos/Uploads), Tenant-Package für Event-Anzahl.
|
||||
- **Fallback**: Bestehende Tenants migrieren zu Free-Paket (z.B. if old_credits > 100 → Standard).
|
||||
- **UI/Features**: Wasserzeichen: if (!$package->watermark_allowed) hide; Branding: Custom Logo-Upload if allowed.
|
||||
- **Billing**: Stripe Products für jedes Package; Webhooks updaten Status (z.B. Subscription cancel → active=false).
|
||||
|
||||
## Migration von Altem System
|
||||
- Entferne: event_credits_balance aus tenants, event_purchases, event_credits_ledger.
|
||||
- Migriere: Für Events mit Credits → Zuordnen zu Free-Paket; Tenant-Balance → Initial Reseller S.
|
||||
- Skript: Artisan Command `php artisan migrate:packages` für Daten-Transfer.
|
||||
|
||||
Dieses Design ist final und bereit für Implementierung. Updates via PR.
|
||||
@@ -12,7 +12,8 @@ This directory supersedes the legacy `fotospiel_prp.md`. Content is split into s
|
||||
- 05-admin-superadmin.md — Super Admin web console (Filament)
|
||||
- 06-tenant-admin-pwa.md — Store-ready Tenant Admin PWA
|
||||
- 07-guest-pwa.md — Guest (event attendee) PWA
|
||||
- 08-billing.md — Event credits MVP, ledger, purchases
|
||||
- 08-billing.md — Packages (Einmalkäufe/Subscriptions), ledger, purchases
|
||||
- 15-packages-design.md — Package definitions, schema, integration
|
||||
- 09-security-compliance.md — RBAC, audit, GDPR
|
||||
- 10-storage-media-pipeline.md — Object storage, processing, CDN
|
||||
- 11-ops-ci-cd.md — CI, releases, environments
|
||||
|
||||
@@ -28,8 +28,8 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
|
||||
### Stats laden
|
||||
- **GET /api/v1/tenant/dashboard**
|
||||
- **Headers**: `Authorization: Bearer {token}`
|
||||
- **Response**: `{ credits, active_events, new_photos, task_progress }`
|
||||
- **Zweck**: Übersicht-Daten für Dashboard-Cards
|
||||
- **Response**: `{ active_package, active_events, new_photos, task_progress }`
|
||||
- **Zweck**: Übersicht-Daten für Dashboard-Cards (active_package: current tenant package info)
|
||||
|
||||
## Events
|
||||
|
||||
@@ -47,9 +47,9 @@ Diese Dokumentation beschreibt alle API-Endpunkte, die die Tenant Admin App mit
|
||||
### Event erstellen
|
||||
- **POST /api/v1/tenant/events**
|
||||
- **Headers**: `Authorization: Bearer {token}`, `Content-Type: application/json`
|
||||
- **Body**: `{ title, date, location, description }`
|
||||
- **Body**: `{ title, date, location, description, package_id }`
|
||||
- **Response**: 201 Created mit erstelltem Event
|
||||
- **Validierung**: Prüft Credit-Balance (1 Credit pro Event)
|
||||
- **Validierung**: Prüft Tenant-Package (Reseller-Limit) und erstellt Event-Package (Einmalkauf oder Free)
|
||||
|
||||
### Event-Details
|
||||
- **GET /api/v1/tenant/events/{slug}**
|
||||
@@ -198,8 +198,8 @@ Alle API-Requests enthalten:
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "INSUFFICIENT_CREDITS",
|
||||
"message": "Nicht genügend Credits verfügbar"
|
||||
"code": "PACKAGE_LIMIT_EXCEEDED",
|
||||
"message": "Package-Limit überschritten (z.B. max_photos)"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -11,7 +11,7 @@ Die Tenant Admin App muss folgende Kernfunktionen bereitstellen:
|
||||
- **Member-Management**: Hinzufügen/Entfernen von Event-Mitgliedern; Rollen (Admin, Member).
|
||||
- **Task & Emotion Management**: Zuweisen von Tasks und Emotions zu Events; Overrides für Tenant-spezifische Bibliotheken.
|
||||
- **Settings-Management**: Tenant-spezifische Einstellungen (Theme, Limits, Legal Pages).
|
||||
- **Billing & Purchases**: Kaufen von Event-Credits; Ledger-Übersicht; Integration mit Stripe.
|
||||
- **Billing & Purchases**: Kaufen von Packages (pro Event oder Tenant); Ledger-Übersicht; Integration mit Stripe.
|
||||
- **Notifications**: Push-Benachrichtigungen für neue Photos, Event-Updates, niedrigen Credit-Balance.
|
||||
- **Offline-Support**: Caching von Events und Photos; Queuing von Uploads/Mutations mit Sync bei Online-Wiederkehr.
|
||||
- **Audit & Compliance**: Logging kritischer Aktionen; ETag-basierte Conflict-Resolution; GDPR-konforme Datenlöschung.
|
||||
@@ -27,7 +27,7 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp
|
||||
|
||||
### Core Features
|
||||
- **Event Lifecycle**:
|
||||
- Erstellen: Erfordert mind. 1 Event-Credit; Slug-Generierung (unique pro Tenant).
|
||||
- Erstellen: Erfordert Package-Auswahl (Free oder Kauf); Slug-Generierung (unique pro Tenant).
|
||||
- Bearbeiten: Update von Datum, Ort, Tasks, Emotions, Join-Link.
|
||||
- Veröffentlichen: Generiert QR-Code und Share-Link; aktiviert Guest-PWA-Zugriff.
|
||||
- Archivieren: Soft-Delete mit Retention-Periode (GDPR); Credit-Rückerstattung optional.
|
||||
@@ -39,8 +39,8 @@ Die App ist API-first und interagiert ausschließlich über den Backend-API-Endp
|
||||
- Bibliothek: Globale + Tenant-spezifische Tasks/Emotions.
|
||||
- Zuweisung: Drag-and-Drop zu Events; Fortschritts-Tracking.
|
||||
- **Billing Integration**:
|
||||
- Credit-Balance: Anzeige und Kauf von Packs (z.B. 5 Events für 29€).
|
||||
- Ledger: Historie von Käufen, Consumptions, Refunds.
|
||||
- Package-Auswahl: Anzeige verfügbarer Packages und Kauf (Einmalkauf/Subscription).
|
||||
- Ledger: Historie von Package-Käufen und Nutzung.
|
||||
- Stripe-Checkout: Server-side Intent-Erstellung; Webhook-Handling für Confirmation.
|
||||
|
||||
### Offline & Sync
|
||||
@@ -62,8 +62,8 @@ Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte
|
||||
|
||||
### Events
|
||||
- `GET /tenant/events`: Liste (paginiert, filterbar nach Status/Datum).
|
||||
- `POST /tenant/events`: Erstellen (validiert Credit-Balance).
|
||||
- `GET /tenant/events/{id}`: Details inkl. Tasks, Stats.
|
||||
- `POST /tenant/events`: Erstellen (validiert Tenant-Package und erstellt Event-Package).
|
||||
- `GET /tenant/events/{id}`: Details inkl. Tasks, Stats, package_limits.
|
||||
- `PATCH /tenant/events/{id}`: Update (ETag für Concurrency).
|
||||
- `DELETE /tenant/events/{id}`: Archivieren.
|
||||
|
||||
@@ -83,13 +83,13 @@ Die App konsumiert den API-Contract aus docs/prp/03-api.md. Schlüssel-Endpunkte
|
||||
- `PATCH /tenant/settings`: Update.
|
||||
|
||||
### Billing
|
||||
- `GET /tenant/ledger`: Credit-Historie.
|
||||
- `POST /tenant/purchases/intent`: Stripe-Checkout-Session erstellen.
|
||||
- `GET /tenant/credits/balance`: Aktueller Stand.
|
||||
- `GET /tenant/packages`: Tenant-Packages und Limits.
|
||||
- `POST /tenant/purchases/intent`: Stripe-Checkout-Session für Package erstellen.
|
||||
- `GET /api/v1/packages`: Verfügbare Packages.
|
||||
|
||||
### Pagination & Errors
|
||||
- Standard: `page`, `per_page` (max 50 für Mobile).
|
||||
- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Nicht genug Credits").
|
||||
- Errors: Parsen von `{ error: { code, message } }`; User-freundliche Messages (z.B. "Package-Limit überschritten").
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Performance**: Ladezeiten < 2s; Lazy-Loading für Galleries.
|
||||
|
||||
@@ -178,3 +178,19 @@ export async function createInviteLink(slug: string): Promise<{ link: string; to
|
||||
const response = await authorizedFetch(`${eventEndpoint(slug)}/invites`, { method: 'POST' });
|
||||
return jsonOrThrow<{ link: string; token: string }>(response, 'Failed to create invite');
|
||||
}
|
||||
|
||||
export type Package = {
|
||||
id: number;
|
||||
name: string;
|
||||
price: number;
|
||||
max_photos: number | null;
|
||||
max_guests: number | null;
|
||||
gallery_days: number | null;
|
||||
features: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export async function getPackages(type: 'endcustomer' | 'reseller' = 'endcustomer'): Promise<Package[]> {
|
||||
const response = await authorizedFetch(`/api/v1/packages?type=${type}`);
|
||||
const data = await jsonOrThrow<{ data: Package[] }>(response, 'Failed to load packages');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Loader2, Save, Sparkles } from 'lucide-react';
|
||||
import { ArrowLeft, Loader2, Save, Sparkles, Package as PackageIcon } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -8,15 +9,18 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { createEvent, getEvent, updateEvent } from '../api';
|
||||
import { createEvent, getEvent, updateEvent, getPackages } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
interface EventFormState {
|
||||
name: string;
|
||||
slug: string;
|
||||
date: string;
|
||||
package_id: number;
|
||||
isPublished: boolean;
|
||||
}
|
||||
|
||||
@@ -30,6 +34,7 @@ export default function EventFormPage() {
|
||||
name: '',
|
||||
slug: '',
|
||||
date: '',
|
||||
package_id: 1, // Default Free package
|
||||
isPublished: false,
|
||||
});
|
||||
const [autoSlug, setAutoSlug] = React.useState(true);
|
||||
@@ -38,6 +43,11 @@ export default function EventFormPage() {
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const { data: packages, isLoading: packagesLoading } = useQuery({
|
||||
queryKey: ['packages', 'endcustomer'],
|
||||
queryFn: () => getPackages('endcustomer'),
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!isEdit || !slugParam) {
|
||||
@@ -109,6 +119,7 @@ export default function EventFormPage() {
|
||||
const payload = {
|
||||
name: trimmedName,
|
||||
slug: trimmedSlug,
|
||||
package_id: form.package_id,
|
||||
date: form.date || undefined,
|
||||
status: form.isPublished ? 'published' : 'draft',
|
||||
};
|
||||
@@ -199,6 +210,50 @@ export default function EventFormPage() {
|
||||
onChange={(e) => setForm((prev) => ({ ...prev, date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="package_id">Package</Label>
|
||||
<Select value={form.package_id.toString()} onValueChange={(value) => setForm((prev) => ({ ...prev, package_id: parseInt(value) }))}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Wählen Sie ein Package" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{packagesLoading ? (
|
||||
<SelectItem value="">Laden...</SelectItem>
|
||||
) : (
|
||||
packages?.map((pkg) => (
|
||||
<SelectItem key={pkg.id} value={pkg.id.toString()}>
|
||||
{pkg.name} - {pkg.price} € ({pkg.max_photos} Fotos)
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm">Package-Details</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Package auswählen</DialogTitle>
|
||||
<DialogDescription>Wählen Sie das Package für Ihr Event. Höhere Packages bieten mehr Limits und Features.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
{packages?.map((pkg) => (
|
||||
<div key={pkg.id} className="p-4 border rounded">
|
||||
<h3 className="font-semibold">{pkg.name}</h3>
|
||||
<p>{pkg.price} €</p>
|
||||
<ul className="text-sm">
|
||||
<li>Max Fotos: {pkg.max_photos}</li>
|
||||
<li>Max Gäste: {pkg.max_guests}</li>
|
||||
<li>Galerie: {pkg.gallery_days} Tage</li>
|
||||
<li>Features: {Object.keys(pkg.features).filter(k => pkg.features[k]).join(', ')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 rounded-xl bg-pink-50/60 p-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles } from 'lucide-react';
|
||||
import { ArrowRight, CalendarDays, Plus, Settings, Sparkles, Package as PackageIcon } from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,7 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getEvents, TenantEvent } from '../api';
|
||||
import { getEvents, TenantEvent, getPackages } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventsPage() {
|
||||
@@ -17,6 +18,11 @@ export default function EventsPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: tenantPackages } = useQuery({
|
||||
queryKey: ['tenant-packages'],
|
||||
queryFn: () => getPackages('reseller'), // or separate endpoint
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -53,6 +59,33 @@ export default function EventsPage() {
|
||||
subtitle="Plane Momente, die in Erinnerung bleiben. Hier verwaltest du alles rund um deine Veranstaltungen."
|
||||
actions={actions}
|
||||
>
|
||||
{tenantPackages && tenantPackages.length > 0 && (
|
||||
<Card className="mb-6 border-0 bg-white/80 shadow-xl shadow-pink-100/60">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PackageIcon className="h-5 w-5 text-pink-500" />
|
||||
Aktuelles Package
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Ihr aktuelles Reseller-Package und verbleibende Limits.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Aktives Package</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.package?.name || 'Kein aktives Package'}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Verbleibende Events</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.remaining_events || 0}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<h3 className="font-semibold">Ablauf</h3>
|
||||
<p className="text-lg font-bold">{tenantPackages.find(p => p.active)?.expires_at || 'Kein Package'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Fehler beim Laden</AlertTitle>
|
||||
|
||||
@@ -2,13 +2,14 @@ import React, { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
import { Heart } from 'lucide-react';
|
||||
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
|
||||
|
||||
export default function GalleryPage() {
|
||||
const { slug } = useParams();
|
||||
@@ -17,6 +18,11 @@ export default function GalleryPage() {
|
||||
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
|
||||
const [event, setEvent] = useState<EventData | null>(null);
|
||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||
const [stats, setStats] = useState<EventStats | null>(null);
|
||||
const [eventLoading, setEventLoading] = useState(true);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
// Auto-open lightbox if photoId in query params
|
||||
@@ -30,6 +36,31 @@ export default function GalleryPage() {
|
||||
}
|
||||
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
|
||||
|
||||
// Load event and package info
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setEventLoading(true);
|
||||
const [eventData, packageData, statsData] = await Promise.all([
|
||||
fetchEvent(slug),
|
||||
getEventPackage(slug),
|
||||
fetchStats(slug),
|
||||
]);
|
||||
setEvent(eventData);
|
||||
setEventPackage(packageData);
|
||||
setStats(statsData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load event data', err);
|
||||
} finally {
|
||||
setEventLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEventData();
|
||||
}, [slug]);
|
||||
|
||||
const myPhotoIds = React.useMemo(() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
@@ -68,19 +99,68 @@ export default function GalleryPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (eventLoading) {
|
||||
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page title="Galerie">
|
||||
<Card className="mx-4 mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
Galerie: {event?.name || 'Event'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center">
|
||||
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
|
||||
<p className="font-semibold">Online Gäste</p>
|
||||
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
|
||||
<p className="font-semibold">Gesamt Likes</p>
|
||||
<p className="text-2xl">{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Camera className="h-8 w-8 mx-auto mb-2 text-green-500" />
|
||||
<p className="font-semibold">Gesamt Fotos</p>
|
||||
<p className="text-2xl">{photos.length}</p>
|
||||
</div>
|
||||
{eventPackage && (
|
||||
<div className="text-center">
|
||||
<PackageIcon className="h-8 w-8 mx-auto mb-2 text-purple-500" />
|
||||
<p className="font-semibold">Package</p>
|
||||
<p className="text-sm">{eventPackage.package.name}</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full"
|
||||
style={{ width: `${(eventPackage.used_photos / eventPackage.package.max_photos) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
{eventPackage.used_photos} / {eventPackage.package.max_photos} Fotos
|
||||
</p>
|
||||
{new Date(eventPackage.expires_at) < new Date() && (
|
||||
<p className="text-red-600 text-xs mt-1">Abgelaufen: {new Date(eventPackage.expires_at).toLocaleDateString()}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<FiltersBar value={filter} onChange={setFilter} />
|
||||
{newCount > 0 && (
|
||||
<Alert className="mb-3">
|
||||
<Alert className="mb-3 mx-4">
|
||||
<AlertDescription>
|
||||
{newCount} neue Fotos verfügbar.{' '}
|
||||
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{loading && <p>Lade…</p>}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{loading && <p className="mx-4">Lade…</p>}
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 px-4">
|
||||
{list.map((p: any) => {
|
||||
// Debug: Log image URLs
|
||||
const imgSrc = p.thumbnail_path || p.file_path;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from 'lucide-react';
|
||||
import { getEventPackage, type EventPackage } from '../services/eventApi';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -85,6 +86,9 @@ export default function UploadPage() {
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
|
||||
const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
const [showPrimer, setShowPrimer] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.localStorage.getItem(primerStorageKey) !== '1';
|
||||
@@ -201,6 +205,30 @@ export default function UploadPage() {
|
||||
};
|
||||
}, [slug, taskId, emotionSlug]);
|
||||
|
||||
// Check upload limits
|
||||
useEffect(() => {
|
||||
if (!slug || !task) return;
|
||||
|
||||
const checkLimits = async () => {
|
||||
try {
|
||||
const pkg = await getEventPackage(slug);
|
||||
setEventPackage(pkg);
|
||||
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
|
||||
setCanUpload(false);
|
||||
setUploadError('Upload-Limit erreicht. Kontaktieren Sie den Organisator für ein Upgrade.');
|
||||
} else {
|
||||
setCanUpload(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check package limits', err);
|
||||
setCanUpload(false);
|
||||
setUploadError('Fehler beim Prüfen des Limits. Upload deaktiviert.');
|
||||
}
|
||||
};
|
||||
|
||||
checkLimits();
|
||||
}, [slug, task]);
|
||||
|
||||
const stopStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
@@ -428,7 +456,7 @@ export default function UploadPage() {
|
||||
);
|
||||
|
||||
const handleUsePhoto = useCallback(async () => {
|
||||
if (!slug || !reviewPhoto || !task) return;
|
||||
if (!slug || !reviewPhoto || !task || !canUpload) return;
|
||||
setMode('uploading');
|
||||
setUploadProgress(5);
|
||||
setUploadError(null);
|
||||
@@ -459,9 +487,10 @@ export default function UploadPage() {
|
||||
}
|
||||
setStatusMessage('');
|
||||
}
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task]);
|
||||
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
|
||||
|
||||
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!canUpload) return;
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setUploadError(null);
|
||||
@@ -474,7 +503,7 @@ export default function UploadPage() {
|
||||
setUploadError('Auswahl fehlgeschlagen. Bitte versuche es erneut.');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}, []);
|
||||
}, [canUpload]);
|
||||
|
||||
const difficultyBadgeClass = useMemo(() => {
|
||||
if (!task) return 'text-white';
|
||||
@@ -491,6 +520,8 @@ export default function UploadPage() {
|
||||
const isCameraActive = permissionState === 'granted' && mode !== 'uploading';
|
||||
const showTaskOverlay = task && mode !== 'uploading';
|
||||
|
||||
const isUploadDisabled = !canUpload || !task;
|
||||
|
||||
useEffect(() => () => {
|
||||
resetCountdownTimer();
|
||||
if (uploadProgressTimerRef.current) {
|
||||
@@ -527,6 +558,24 @@ export default function UploadPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canUpload) {
|
||||
return (
|
||||
<div className="pb-16">
|
||||
<Header slug={slug} title="Kamera" />
|
||||
<main className="px-4 py-6">
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Upload-Limit erreicht ({eventPackage?.used_photos || 0} / {eventPackage?.package.max_photos || 0} Fotos).
|
||||
Kontaktieren Sie den Organisator für ein Package-Upgrade.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</main>
|
||||
<BottomNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderPrimer = () => (
|
||||
showPrimer && (
|
||||
<div className="mx-4 mt-3 rounded-xl border border-pink-200 bg-white/90 p-4 text-sm text-pink-900 shadow">
|
||||
|
||||
@@ -14,6 +14,19 @@ export interface EventData {
|
||||
};
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
id: number;
|
||||
name: string;
|
||||
max_photos: number;
|
||||
}
|
||||
|
||||
export interface EventPackage {
|
||||
id: number;
|
||||
used_photos: number;
|
||||
expires_at: string;
|
||||
package: PackageData;
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
onlineGuests: number;
|
||||
tasksSolved: number;
|
||||
@@ -40,3 +53,12 @@ export async function fetchStats(slug: string): Promise<EventStats> {
|
||||
latestPhotoAt: json.latestPhotoAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
|
||||
const res = await fetch(`/api/v1/events/${slug}/package`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) return null;
|
||||
throw new Error('Failed to load event package');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
40
resources/lang/de/marketing.php
Normal file
40
resources/lang/de/marketing.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'packages' => [
|
||||
'title' => 'Unsere Packages – Wählen Sie Ihr Event-Paket',
|
||||
'hero_title' => 'Entdecken Sie unsere flexiblen Packages',
|
||||
'hero_description' => 'Von kostenlosem Einstieg bis Premium-Features: Passen Sie Ihr Event-Paket an Ihre Bedürfnisse an. Einfach, sicher und skalierbar.',
|
||||
'cta_explore' => 'Packages entdecken',
|
||||
'tab_endcustomer' => 'Endkunden',
|
||||
'tab_reseller' => 'Reseller & Agenturen',
|
||||
'section_endcustomer' => 'Packages für Endkunden (Einmalkauf pro Event)',
|
||||
'section_reseller' => 'Packages für Reseller (Jährliches Abo)',
|
||||
'free' => 'Kostenlos',
|
||||
'one_time' => 'Einmalkauf',
|
||||
'subscription' => 'Abo',
|
||||
'year' => 'Jahr',
|
||||
'max_photos' => 'Fotos',
|
||||
'max_guests' => 'Gäste',
|
||||
'gallery_days' => 'Tage Galerie',
|
||||
'max_events_year' => 'Events/Jahr',
|
||||
'buy_now' => 'Jetzt kaufen',
|
||||
'subscribe_now' => 'Jetzt abonnieren',
|
||||
'faq_title' => 'Häufige Fragen zu Packages',
|
||||
'faq_q1' => 'Was ist ein Package?',
|
||||
'faq_a1' => 'Ein Package definiert Limits und Features für Ihr Event, z.B. Anzahl Fotos und Galerie-Dauer.',
|
||||
'faq_q2' => 'Kann ich upgraden?',
|
||||
'faq_a2' => 'Ja, wählen Sie bei Event-Erstellung ein höheres Package oder upgraden Sie später.',
|
||||
'faq_q3' => 'Was passiert bei Ablauf?',
|
||||
'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.',
|
||||
'faq_q4' => 'Zahlungssicher?',
|
||||
'faq_a4' => 'Ja, via Stripe oder PayPal – sicher und GDPR-konform.',
|
||||
'final_cta' => 'Bereit für Ihr nächstes Event?',
|
||||
'contact_us' => 'Kontaktieren Sie uns',
|
||||
'feature_live_slideshow' => 'Live-Slideshow',
|
||||
'feature_analytics' => 'Analytics',
|
||||
'feature_watermark' => 'Wasserzeichen',
|
||||
'feature_branding' => 'Branding',
|
||||
'feature_support' => 'Support',
|
||||
],
|
||||
];
|
||||
@@ -10,6 +10,9 @@
|
||||
<p>Wir nehmen den Schutz Ihrer persönlichen Daten sehr ernst und halten uns strikt an die Regeln der Datenschutzgesetze.</p>
|
||||
<p>Verantwortlich: Fotospiel GmbH, Musterstraße 1, 12345 Musterstadt</p>
|
||||
<p>Datenerfassung: Keine PII-Speicherung, anonyme Sessions für Gäste. E-Mails werden nur für Kontaktzwecke verarbeitet.</p>
|
||||
<h2>Zahlungen und Packages</h2>
|
||||
<p>Wir verarbeiten Zahlungen für Packages über Stripe und PayPal. Karteninformationen werden nicht gespeichert – alle Daten werden verschlüsselt übertragen. Siehe <a href="https://stripe.com/de/privacy" target="_blank">Stripe Datenschutz</a> und <a href="https://www.paypal.com/de/webapps/mpp/ua/privacy-full" target="_blank">PayPal Datenschutz</a>.</p>
|
||||
<p>Package-Daten (Limits, Features) sind anonymisiert und werden nur für die Funktionalität benötigt. Consent für Zahlungen und E-Mails wird bei Kauf eingeholt. Daten werden nach 10 Jahren gelöscht.</p>
|
||||
<p>Ihre Rechte: Auskunft, Löschung, Widerspruch. Kontaktieren Sie uns unter <a href="/kontakt">Kontakt</a>.</p>
|
||||
<p>Cookies: Nur funktionale Cookies für die PWA.</p>
|
||||
</body>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
Vertreten durch: Max Mustermann<br>
|
||||
Kontakt: <a href="/kontakt">Kontakt</a></p>
|
||||
<p class="mb-4">Umsatzsteuer-ID: DE123456789</p>
|
||||
<h2>Monetarisierung</h2>
|
||||
<p>Wir monetarisieren über Packages (Einmalkäufe und Abos) via Stripe und PayPal. Preise exkl. MwSt. Support: support@fotospiel.de</p>
|
||||
<p>Registergericht: Amtsgericht Musterstadt</p>
|
||||
<p>Handelsregister: HRB 12345</p>
|
||||
</body>
|
||||
|
||||
@@ -43,9 +43,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<a href="/blog" class="text-gray-600 hover:text-gray-900">Blog</a>
|
||||
<a href="#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/packages" class="text-gray-600 hover:text-gray-900">Packages</a>
|
||||
<a href="#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Jetzt starten</a>
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold hover:bg-[#FF69B4] transition">Packages entdecken</a>
|
||||
</nav>
|
||||
<!-- Mobile Menu Placeholder (Hamburger) -->
|
||||
<button class="md:hidden text-gray-600">☰</button>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="md:w-1/2 text-center md:text-left">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Fotospiel</h1>
|
||||
<p class="text-xl md:text-2xl mb-8">Sammle Gastfotos für Events mit QR-Codes. Unsere sichere PWA-Plattform für Gäste und Organisatoren – einfach, mobil und datenschutzkonform. Besser als Konkurrenz, geliebt von Tausenden.</p>
|
||||
<a href="/buy-credits/basic" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Jetzt starten – Kostenlos</a>
|
||||
<a href="/packages" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Jetzt starten – Kostenlos</a>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<img src="https://images.unsplash.com/photo-1511285560929-80b456fea0bc?w=600&h=400&fit=crop" alt="Event-Fotos mit QR" class="rounded-lg shadow-lg w-full" style="filter: drop-shadow(0 10px 8px rgba(0,0,0,0.1));">
|
||||
@@ -125,41 +125,15 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing Section id="pricing" -->
|
||||
<!-- Packages Teaser Section -->
|
||||
<section id="pricing" class="py-20 px-4 bg-gray-50">
|
||||
<div class="container mx-auto">
|
||||
<h2 class="text-3xl font-bold text-center mb-12">Tarife für QR-Events</h2>
|
||||
<div class="grid md:grid-cols-3 gap-8 max-w-4xl mx-auto">
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-gray-200">
|
||||
<h3 class="text-2xl font-bold mb-4">Basic</h3>
|
||||
<p class="text-4xl font-bold text-[#FFB6C1] mb-4">0 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>1 Event mit QR</li>
|
||||
<li>100 Fotos</li>
|
||||
<li>Grundfunktionen</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-3 rounded-full font-semibold">Kostenlos starten</a>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-[#FFD700]">
|
||||
<h3 class="text-2xl font-bold mb-4">Standard</h3>
|
||||
<p class="text-4xl font-bold text-[#FFD700] mb-4">99 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>10 Events mit QR</li>
|
||||
<li>Unbegrenzt Fotos</li>
|
||||
<li>Erweiterte Features</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/standard" class="bg-[#FFD700] text-white px-6 py-3 rounded-full font-semibold">Kaufen</a>
|
||||
</div>
|
||||
<div class="bg-white p-8 rounded-lg text-center border-2 border-gray-200">
|
||||
<h3 class="text-2xl font-bold mb-4">Premium</h3>
|
||||
<p class="text-4xl font-bold text-[#87CEEB] mb-4">199 €</p>
|
||||
<ul class="mb-6 space-y-2">
|
||||
<li>50 Events mit QR</li>
|
||||
<li>Support & Custom</li>
|
||||
<li>Alle Features</li>
|
||||
</ul>
|
||||
<a href="/buy-credits/premium" class="bg-[#87CEEB] text-white px-6 py-3 rounded-full font-semibold">Kaufen</a>
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold text-center mb-12">Unsere Packages</h2>
|
||||
<p class="text-center text-lg text-gray-600 mb-8">Wählen Sie das passende Paket für Ihr Event – von kostenlos bis premium.</p>
|
||||
<div class="text-center">
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-8 py-4 rounded-full font-semibold text-lg hover:bg-[#FF69B4] transition">
|
||||
Alle Packages ansehen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<a href="/marketing#pricing" class="text-gray-600 hover:text-gray-900">Pricing</a>
|
||||
<a href="/marketing#contact" class="text-gray-600 hover:text-gray-900">Contact</a>
|
||||
</nav>
|
||||
<a href="/buy-credits/basic" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Jetzt starten</a>
|
||||
<a href="/packages" class="bg-[#FFB6C1] text-white px-6 py-2 rounded-full font-semibold">Packages wählen</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="container mx-auto text-center">
|
||||
<h1 class="text-4xl md:text-6xl font-bold mb-4">Fotospiel für {{ ucfirst($type) }}</h1>
|
||||
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Sammle unvergessliche Fotos von deinen Gästen mit QR-Codes. Perfekt für {{ ucfirst($type) }} – einfach, mobil und datenschutzkonform.</p>
|
||||
<a href="/buy-credits/basic" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Event starten</a>
|
||||
<a href="/packages" class="bg-white text-[#FFB6C1] px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition">Package wählen</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
194
resources/views/marketing/packages.blade.php
Normal file
194
resources/views/marketing/packages.blade.php
Normal file
@@ -0,0 +1,194 @@
|
||||
@extends('layouts.marketing')
|
||||
|
||||
@section('title', __('marketing.packages.title'))
|
||||
|
||||
@section('content')
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Hero Section -->
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-4xl font-bold text-gray-900 mb-4">
|
||||
{{ __('marketing.packages.hero_title') }}
|
||||
</h1>
|
||||
<p class="text-xl text-gray-600 mb-8">
|
||||
{{ __('marketing.packages.hero_description') }}
|
||||
</p>
|
||||
<a href="#endcustomer" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-full font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.cta_explore') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Tabs for Package Types -->
|
||||
<div class="mb-12">
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<a href="#endcustomer" class="tab-link whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-blue-500 text-blue-600">
|
||||
{{ __('marketing.packages.tab_endcustomer') }}
|
||||
</a>
|
||||
<a href="#reseller" class="tab-link whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm border-gray-300 text-gray-500 hover:text-gray-700 hover:border-gray-300">
|
||||
{{ __('marketing.packages.tab_reseller') }}
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endcustomer Packages -->
|
||||
<section id="endcustomer" class="mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
{{ __('marketing.packages.section_endcustomer') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@foreach(\App\Models\Package::where('type', 'endcustomer')->orderBy('price')->get() as $package)
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
|
||||
<div class="text-3xl font-bold text-blue-600 mt-2">
|
||||
{{ $package->price }} €
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ $package->price == 0 ? __('marketing.packages.free') : __('marketing.packages.one_time') }}</p>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-6">
|
||||
@if($package->max_photos)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_photos }} {{ __('marketing.packages.max_photos') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->max_guests)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_guests }} {{ __('marketing.packages.max_guests') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->gallery_days)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->gallery_days }} {{ __('marketing.packages.gallery_days') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->features)
|
||||
@foreach(json_decode($package->features, true) as $feature => $enabled)
|
||||
@if($enabled)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ __('marketing.packages.feature_' . $feature) }}
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</ul>
|
||||
<a href="/packages?type=endcustomer&package_id={{ $package->id }}" class="w-full bg-blue-600 hover:bg-blue-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.buy_now') }}
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Reseller Packages -->
|
||||
<section id="reseller" class="mb-16">
|
||||
<h2 class="text-3xl font-bold text-gray-900 mb-8 text-center">
|
||||
{{ __('marketing.packages.section_reseller') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
@foreach(\App\Models\Package::where('type', 'reseller')->orderBy('price')->get() as $package)
|
||||
<div class="bg-white rounded-lg shadow-md p-6 border border-gray-200 hover:shadow-lg transition duration-300">
|
||||
<div class="text-center mb-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900">{{ $package->name }}</h3>
|
||||
<div class="text-3xl font-bold text-blue-600 mt-2">
|
||||
{{ $package->price }} € / {{ __('marketing.packages.year') }}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">{{ __('marketing.packages.subscription') }}</p>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-6">
|
||||
@if($package->max_events_per_year)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ $package->max_events_per_year }} {{ __('marketing.packages.max_events_year') }}
|
||||
</li>
|
||||
@endif
|
||||
@if($package->features)
|
||||
@foreach(json_decode($package->features, true) as $feature => $enabled)
|
||||
@if($enabled)
|
||||
<li class="flex items-center text-sm text-gray-700">
|
||||
<svg class="w-4 h-4 text-green-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{{ __('marketing.packages.feature_' . $feature) }}
|
||||
</li>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</ul>
|
||||
<a href="/packages?type=reseller&package_id={{ $package->id }}" class="w-full bg-green-600 hover:bg-green-700 text-white py-2 px-4 rounded-md text-center font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.subscribe_now') }}
|
||||
</a>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="bg-white rounded-lg shadow-md p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-6 text-center">
|
||||
{{ __('marketing.packages.faq_title') }}
|
||||
</h2>
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q1') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a1') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q2') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a2') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q3') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a3') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-900 mb-2">{{ __('marketing.packages.faq_q4') }}</h3>
|
||||
<p class="text-gray-600">{{ __('marketing.packages.faq_a4') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA Section -->
|
||||
<div class="text-center mt-16">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-4">
|
||||
{{ __('marketing.packages.final_cta') }}
|
||||
</h2>
|
||||
<a href="/contact" class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-full font-semibold transition duration-300">
|
||||
{{ __('marketing.packages.contact_us') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@push('scripts')
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const tabLinks = document.querySelectorAll('.tab-link');
|
||||
tabLinks.forEach(link => {
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
const target = this.getAttribute('href');
|
||||
document.querySelectorAll('section').forEach(section => section.style.display = 'none');
|
||||
document.querySelector(target).style.display = 'block';
|
||||
tabLinks.forEach(l => l.classList.remove('border-blue-500', 'text-blue-600'));
|
||||
this.classList.add('border-blue-500', 'text-blue-600');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@endpush
|
||||
@@ -7,7 +7,8 @@ use App\Http\Controllers\Api\Tenant\TaskController;
|
||||
use App\Http\Controllers\Api\Tenant\PhotoController;
|
||||
use App\Http\Controllers\OAuthController;
|
||||
use App\Http\Controllers\RevenueCatWebhookController;
|
||||
use App\Http\Controllers\Tenant\CreditController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\TenantPackageController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
@@ -86,12 +87,22 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
->name('tenant.settings.validate-domain');
|
||||
});
|
||||
|
||||
Route::prefix('credits')->group(function () {
|
||||
Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance');
|
||||
Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger');
|
||||
Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history');
|
||||
Route::post('purchase', [CreditController::class, 'purchase'])->name('tenant.credits.purchase');
|
||||
Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync');
|
||||
Route::prefix('packages')->group(function () {
|
||||
Route::get('/', [PackageController::class, 'index'])->name('packages.index');
|
||||
Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase');
|
||||
});
|
||||
|
||||
Route::prefix('stripe')->group(function () {
|
||||
Route::post('/payment-intent', [StripeController::class, 'createPaymentIntent'])->name('stripe.payment-intent');
|
||||
Route::post('/subscription', [StripeController::class, 'createSubscription'])->name('stripe.subscription');
|
||||
});
|
||||
|
||||
Route::prefix('tenant/packages')->group(function () {
|
||||
Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index');
|
||||
});
|
||||
});
|
||||
|
||||
// Stripe Webhook (no auth)
|
||||
Route::post('/stripe/webhook', [StripeWebhookController::class, 'handleWebhook'])
|
||||
->name('stripe.webhook');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ use Inertia\Inertia;
|
||||
// Marketing-Seite mit Locale-Prefix
|
||||
Route::prefix('{locale?}')->where(['locale' => 'de|en'])->middleware('locale')->group(function () {
|
||||
Route::view('/', 'marketing')->name('marketing');
|
||||
Route::view('/packages', 'marketing.packages')->name('packages');
|
||||
Route::get('/occasions/{type}', function ($type) {
|
||||
return view('marketing.occasions', ['type' => $type]);
|
||||
})->name('occasions.type');
|
||||
|
||||
127
tests/Feature/EventControllerTest.php
Normal file
127
tests/Feature/EventControllerTest.php
Normal file
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\Package;
|
||||
use App\Models\Event;
|
||||
use App\Models\User;
|
||||
use App\Models\TenantPackage;
|
||||
use App\Models\EventPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_create_event_with_valid_package_succeeds(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 100]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
'date' => '2025-10-01',
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('events', [
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
]);
|
||||
|
||||
$event = Event::latest()->first();
|
||||
$this->assertDatabaseHas('event_packages', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'type' => 'endcustomer_event',
|
||||
'provider_id' => 'manual',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_create_event_without_package_fails(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Test Event',
|
||||
'slug' => 'test-event',
|
||||
'date' => '2025-10-01',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['package_id']);
|
||||
}
|
||||
|
||||
public function test_create_event_with_reseller_package_limits_events(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'reseller', 'max_events_per_year' => 1]);
|
||||
TenantPackage::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'package_id' => $package->id,
|
||||
'used_events' => 0,
|
||||
'active' => true,
|
||||
'expires_at' => now()->addYear(),
|
||||
]);
|
||||
|
||||
// First event succeeds
|
||||
$response1 = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'First Event',
|
||||
'slug' => 'first-event',
|
||||
'date' => '2025-10-01',
|
||||
'package_id' => $package->id, // Use reseller package for event? Adjust if needed
|
||||
]);
|
||||
|
||||
$response1->assertStatus(201);
|
||||
|
||||
// Second event fails due to limit
|
||||
$response2 = $this->actingAs($user)
|
||||
->postJson('/api/v1/tenant/events', [
|
||||
'name' => 'Second Event',
|
||||
'slug' => 'second-event',
|
||||
'date' => '2025-10-02',
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
|
||||
$response2->assertStatus(402)
|
||||
->assertJson(['error' => 'No available package for event creation']);
|
||||
}
|
||||
|
||||
public function test_upload_exceeds_package_limit_fails(): void
|
||||
{
|
||||
$tenant = Tenant::factory()->create();
|
||||
$user = User::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$event = Event::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer', 'max_photos' => 0]); // Limit 0
|
||||
EventPackage::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'used_photos' => 0,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)
|
||||
->postJson("/api/v1/events/{$event->slug}/photos", [
|
||||
'photo' => 'test-photo.jpg',
|
||||
]);
|
||||
|
||||
$response->assertStatus(402)
|
||||
->assertJson(['error' => 'Upload limit reached for this event']);
|
||||
}
|
||||
}
|
||||
120
tests/Feature/StripeWebhookTest.php
Normal file
120
tests/Feature/StripeWebhookTest.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\Api\StripeWebhookController;
|
||||
use App\Models\Package;
|
||||
use App\Models\PackagePurchase;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\TenantPackage;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Stripe\Webhook;
|
||||
use Tests\TestCase;
|
||||
|
||||
class StripeWebhookTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// Mock Stripe secret
|
||||
config(['services.stripe.webhook_secret' => 'whsec_test_secret']);
|
||||
}
|
||||
|
||||
public function test_handle_payment_intent_succeeded_creates_event_package(): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
$event = \App\Models\Event::factory()->create(['tenant_id' => $tenant->id]);
|
||||
$package = Package::factory()->create(['type' => 'endcustomer']);
|
||||
|
||||
$payload = [
|
||||
'id' => 'evt_test',
|
||||
'type' => 'payment_intent.succeeded',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'id' => 'pi_test',
|
||||
'metadata' => [
|
||||
'type' => 'endcustomer_event',
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'event_id' => (string) $event->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'event_id' => $event->id,
|
||||
'type' => 'endcustomer_event',
|
||||
'provider_id' => 'pi_test',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('event_packages', [
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_handle_invoice_paid_renews_tenant_package(): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::factory()->create();
|
||||
$package = Package::factory()->create(['type' => 'reseller']);
|
||||
|
||||
$payload = [
|
||||
'id' => 'evt_test',
|
||||
'type' => 'invoice.paid',
|
||||
'data' => [
|
||||
'object' => [
|
||||
'subscription' => 'sub_test',
|
||||
'metadata' => [
|
||||
'type' => 'reseller_subscription',
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'package_id' => (string) $package->id,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$sigHeader = 't=12345,v1=' . base64_encode(hash_hmac('sha256', json_encode($payload), 'whsec_test_secret', true));
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseHas('package_purchases', [
|
||||
'package_id' => $package->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'type' => 'reseller_subscription',
|
||||
]);
|
||||
|
||||
$tenantPackage = TenantPackage::where('tenant_id', $tenant->id)->first();
|
||||
$this->assertNotNull($tenantPackage);
|
||||
$this->assertTrue($tenantPackage->expires_at->isFuture());
|
||||
}
|
||||
|
||||
public function test_webhook_rejects_invalid_signature(): void
|
||||
{
|
||||
$payload = ['type' => 'invalid'];
|
||||
$sigHeader = 'invalid';
|
||||
|
||||
$response = $this->postJson('/api/v1/stripe/webhook', $payload, [
|
||||
'Stripe-Signature' => $sigHeader,
|
||||
]);
|
||||
|
||||
$response->assertStatus(400);
|
||||
}
|
||||
}
|
||||
60
tests/e2e/package-flow.test.ts
Normal file
60
tests/e2e/package-flow.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
test.describe('Package Flow in Admin PWA', () => {
|
||||
test('Create event with package and verify limits', async ({ page }) => {
|
||||
// Assume logged in as tenant admin, navigate to events page
|
||||
await page.goto('/admin/events');
|
||||
|
||||
// Click create event button
|
||||
await page.click('[data-testid="create-event"]');
|
||||
await expect(page).toHaveURL(/\/admin\/events\/create/);
|
||||
|
||||
// Fill form
|
||||
await page.fill('[name="name"]', 'Test Package Event');
|
||||
await page.fill('[name="slug"]', 'test-package-event');
|
||||
await page.fill('[name="date"]', '2025-10-01');
|
||||
|
||||
// Select package from dropdown
|
||||
await page.selectOption('[name="package_id"]', '1'); // Assume ID 1 is Starter package
|
||||
await expect(page.locator('[name="package_id"]')).toHaveValue('1');
|
||||
|
||||
// Submit
|
||||
await page.click('[type="submit"]');
|
||||
await expect(page).toHaveURL(/\/admin\/events/);
|
||||
|
||||
// Verify event created and package assigned
|
||||
await expect(page.locator('text=Test Package Event')).toBeVisible();
|
||||
await expect(page.locator('text=Starter')).toBeVisible(); // Package name in table
|
||||
|
||||
// Check dashboard limits
|
||||
await page.goto('/admin/dashboard');
|
||||
await expect(page.locator('text=Remaining Photos')).toContainText('300'); // Starter limit
|
||||
|
||||
// Try to create another event to test reseller limit if applicable
|
||||
// (Skip for endcustomer; assume tenant has reseller package with limit 1)
|
||||
await page.goto('/admin/events');
|
||||
await page.click('[data-testid="create-event"]');
|
||||
await page.fill('[name="name"]', 'Second Event');
|
||||
await page.fill('[name="slug"]', 'second-event');
|
||||
await page.fill('[name="date"]', '2025-10-02');
|
||||
await page.selectOption('[name="package_id"]', '1');
|
||||
await page.click('[type="submit"]');
|
||||
|
||||
// If limit reached, expect error
|
||||
await expect(page.locator('text=No available package')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Upload blocked when package limit reached in Guest PWA', async ({ page }) => {
|
||||
// Assume event with package limit 0 created
|
||||
await page.goto('/e/test-limited-event'); // Slug of event with max_photos = 0
|
||||
|
||||
// Navigate to upload
|
||||
await page.click('text=Upload');
|
||||
await expect(page).toHaveURL(/\/upload/);
|
||||
|
||||
// Expect upload disabled and error message
|
||||
await expect(page.locator('button:disabled')).toBeVisible(); // Upload button disabled
|
||||
await expect(page.locator('text=Upload-Limit erreicht')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user