diff --git a/app/Filament/Resources/PackageAddonResource.php b/app/Filament/Resources/PackageAddonResource.php new file mode 100644 index 0000000..55f895b --- /dev/null +++ b/app/Filament/Resources/PackageAddonResource.php @@ -0,0 +1,147 @@ +schema([ + Section::make('Add-on Details') + ->columns(2) + ->schema([ + TextInput::make('label') + ->label('Label') + ->required() + ->maxLength(255), + TextInput::make('key') + ->label('Schlüssel') + ->required() + ->unique(ignoreRecord: true) + ->maxLength(191), + TextInput::make('price_id') + ->label('Paddle Preis-ID') + ->helperText('Paddle Billing Preis-ID für dieses Add-on') + ->maxLength(191), + TextInput::make('sort') + ->label('Sortierung') + ->numeric() + ->default(0), + Toggle::make('active') + ->label('Aktiv') + ->default(true), + ]), + Section::make('Limits-Inkremente') + ->columns(3) + ->schema([ + TextInput::make('extra_photos') + ->label('Extra Fotos') + ->numeric() + ->minValue(0) + ->default(0), + TextInput::make('extra_guests') + ->label('Extra Gäste') + ->numeric() + ->minValue(0) + ->default(0), + TextInput::make('extra_gallery_days') + ->label('Galerie +Tage') + ->numeric() + ->minValue(0) + ->default(0), + ]), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + TextColumn::make('label') + ->label('Label') + ->searchable() + ->sortable(), + TextColumn::make('key') + ->label('Schlüssel') + ->copyable() + ->sortable(), + TextColumn::make('price_id') + ->label('Paddle Preis-ID') + ->toggleable() + ->copyable(), + TextColumn::make('extra_photos')->label('Fotos +'), + TextColumn::make('extra_guests')->label('Gäste +'), + TextColumn::make('extra_gallery_days')->label('Galerietage +'), + BadgeColumn::make('active') + ->label('Status') + ->colors([ + 'success' => true, + 'danger' => false, + ]) + ->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'), + TextColumn::make('sort') + ->label('Sort') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->filters([ + Tables\Filters\TernaryFilter::make('active') + ->label('Aktiv'), + ]) + ->actions([ + Actions\Action::make('syncPaddle') + ->label('Mit Paddle synchronisieren') + ->icon('heroicon-o-cloud-arrow-up') + ->action(function (PackageAddon $record) { + SyncPackageAddonToPaddle::dispatch($record->id); + + Notification::make() + ->success() + ->title('Paddle-Sync gestartet') + ->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.') + ->send(); + }), + Actions\EditAction::make(), + ]) + ->bulkActions([ + Actions\BulkActionGroup::make([ + Actions\DeleteBulkAction::make(), + ]), + ]); + } + + public static function getPages(): array + { + return [ + 'index' => Pages\ListPackageAddons::route('/'), + 'create' => Pages\CreatePackageAddon::route('/create'), + 'edit' => Pages\EditPackageAddon::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/PackageAddonResource/Pages/CreatePackageAddon.php b/app/Filament/Resources/PackageAddonResource/Pages/CreatePackageAddon.php new file mode 100644 index 0000000..58c8f79 --- /dev/null +++ b/app/Filament/Resources/PackageAddonResource/Pages/CreatePackageAddon.php @@ -0,0 +1,11 @@ +catalog->all()) + ->filter(fn (array $addon) => ! empty($addon['price_id'])) + ->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key])) + ->values() + ->all(); + + return response()->json(['data' => $addons]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/EventAddonController.php b/app/Http/Controllers/Api/Tenant/EventAddonController.php new file mode 100644 index 0000000..5f76b18 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/EventAddonController.php @@ -0,0 +1,114 @@ +attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return ApiError::response( + 'event_not_found', + 'Event not accessible', + __('Das Event konnte nicht gefunden werden.'), + 404, + ['event_slug' => $event->slug ?? null] + ); + } + + $eventPackage = $event->eventPackage ?: $event->eventPackages() + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + + if ($eventPackage) { + $event->setRelation('eventPackage', $eventPackage); + } + + $checkout = $this->checkoutService->createCheckout( + $request->attributes->get('tenant'), + $event, + $request->validated(), + ); + + return response()->json([ + 'checkout_url' => $checkout['checkout_url'] ?? null, + 'checkout_id' => $checkout['id'] ?? null, + 'expires_at' => $checkout['expires_at'] ?? null, + ]); + } + + public function apply(EventAddonRequest $request, Event $event): JsonResponse + { + $tenantId = $request->attributes->get('tenant_id'); + + if ($event->tenant_id !== $tenantId) { + return ApiError::response( + 'event_not_found', + 'Event not accessible', + __('Das Event konnte nicht gefunden werden.'), + 404, + ['event_slug' => $event->slug ?? null] + ); + } + + $eventPackage = $event->eventPackage; + + if (! $eventPackage && method_exists($event, 'eventPackages')) { + $eventPackage = $event->eventPackages() + ->with('package') + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->first(); + } + + if (! $eventPackage) { + return ApiError::response( + 'event_package_missing', + 'Event package missing', + __('Kein Paket ist diesem Event zugeordnet.'), + 409, + ['event_slug' => $event->slug ?? null] + ); + } + + $data = $request->validated(); + + $eventPackage->fill([ + 'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0), + 'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0), + 'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0), + ]); + + if (isset($data['extend_gallery_days'])) { + $base = $eventPackage->gallery_expires_at ?? Carbon::now(); + $eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']); + } + + $eventPackage->save(); + + $event->load([ + 'eventPackage.package', + 'eventPackages.package', + ]); + + return response()->json([ + 'message' => __('Add-ons applied successfully.'), + 'data' => new EventResource($event), + ]); + } +} diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 3aa6273..6a87daf 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -192,7 +192,7 @@ class EventController extends Controller }); $tenant->refresh(); - $event->load(['eventType', 'tenant', 'eventPackages.package']); + $event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']); return response()->json([ 'message' => 'Event created successfully', @@ -222,7 +222,7 @@ class EventController extends Controller 'tasks', 'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'), 'eventPackages' => fn ($query) => $query - ->with('package') + ->with(['package', 'addons']) ->orderByDesc('purchased_at') ->orderByDesc('created_at'), ]); diff --git a/app/Http/Controllers/Api/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index 9d06f43..92e04ad 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\EventPackageAddon; use App\Services\Paddle\PaddleTransactionService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; class TenantBillingController extends Controller @@ -60,4 +62,58 @@ class TenantBillingController extends Controller 'meta' => $result['meta'], ]); } + + public function addons(Request $request): JsonResponse + { + $tenant = $request->attributes->get('tenant'); + + if (! $tenant) { + return response()->json([ + 'data' => [], + 'message' => 'Tenant not found.', + ], 404); + } + + $perPage = max(1, min((int) $request->query('per_page', 25), 100)); + $page = max(1, (int) $request->query('page', 1)); + + $paginator = EventPackageAddon::query() + ->where('tenant_id', $tenant->id) + ->with(['event:id,name,slug']) + ->orderByDesc('purchased_at') + ->orderByDesc('created_at') + ->paginate($perPage, ['*'], 'page', $page); + + $data = $paginator->getCollection()->map(function (EventPackageAddon $addon) { + return [ + 'id' => $addon->id, + 'addon_key' => $addon->addon_key, + 'label' => $addon->metadata['label'] ?? null, + 'quantity' => (int) ($addon->quantity ?? 1), + 'status' => $addon->status, + 'amount' => $addon->amount !== null ? (float) $addon->amount : null, + 'currency' => $addon->currency, + 'extra_photos' => (int) $addon->extra_photos, + 'extra_guests' => (int) $addon->extra_guests, + 'extra_gallery_days' => (int) $addon->extra_gallery_days, + 'purchased_at' => $addon->purchased_at?->toIso8601String(), + 'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'), + 'event' => $addon->event ? [ + 'id' => $addon->event->id, + 'slug' => $addon->event->slug, + 'name' => $addon->event->name, + ] : null, + ]; + })->values(); + + return response()->json([ + 'data' => $data, + 'meta' => [ + 'current_page' => $paginator->currentPage(), + 'last_page' => $paginator->lastPage(), + 'per_page' => $paginator->perPage(), + 'total' => $paginator->total(), + ], + ]); + } } diff --git a/app/Http/Controllers/PaddleWebhookController.php b/app/Http/Controllers/PaddleWebhookController.php index 82da983..4c6390b 100644 --- a/app/Http/Controllers/PaddleWebhookController.php +++ b/app/Http/Controllers/PaddleWebhookController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Services\Addons\EventAddonWebhookService; use App\Services\Checkout\CheckoutWebhookService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -10,7 +11,10 @@ use Symfony\Component\HttpFoundation\Response; class PaddleWebhookController extends Controller { - public function __construct(private readonly CheckoutWebhookService $webhooks) {} + public function __construct( + private readonly CheckoutWebhookService $webhooks, + private readonly EventAddonWebhookService $addonWebhooks, + ) {} public function handle(Request $request): JsonResponse { @@ -31,6 +35,7 @@ class PaddleWebhookController extends Controller if ($eventType) { $handled = $this->webhooks->handlePaddleEvent($payload); + $handled = $this->addonWebhooks->handle($payload) || $handled; } Log::info('Paddle webhook processed', [ diff --git a/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php b/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php new file mode 100644 index 0000000..16e560f --- /dev/null +++ b/app/Http/Requests/Tenant/EventAddonCheckoutRequest.php @@ -0,0 +1,32 @@ + + */ + public function rules(): array + { + /** @var EventAddonCatalog $catalog */ + $catalog = app(EventAddonCatalog::class); + $keys = array_keys($catalog->all()); + $inRule = $keys === [] ? 'string' : 'in:'.implode(',', $keys); + + return [ + 'addon_key' => ['required', 'string', $inRule], + 'quantity' => ['nullable', 'integer', 'min:1', 'max:50'], + 'success_url' => ['nullable', 'url'], + 'cancel_url' => ['nullable', 'url'], + ]; + } +} diff --git a/app/Http/Requests/Tenant/EventAddonRequest.php b/app/Http/Requests/Tenant/EventAddonRequest.php new file mode 100644 index 0000000..442ec7c --- /dev/null +++ b/app/Http/Requests/Tenant/EventAddonRequest.php @@ -0,0 +1,40 @@ + + */ + public function rules(): array + { + return [ + 'extra_photos' => ['nullable', 'integer', 'min:1'], + 'extra_guests' => ['nullable', 'integer', 'min:1'], + 'extend_gallery_days' => ['nullable', 'integer', 'min:1'], + 'reason' => ['nullable', 'string', 'max:255'], + ]; + } + + protected function passedValidation(): void + { + if ( + $this->input('extra_photos') === null + && $this->input('extra_guests') === null + && $this->input('extend_gallery_days') === null + ) { + throw ValidationException::withMessages([ + 'addons' => __('Please provide at least one add-on to apply.'), + ]); + } + } +} diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index 5889a53..79516f9 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -6,6 +6,7 @@ use App\Services\Packages\PackageLimitEvaluator; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; +use Illuminate\Support\Arr; class EventResource extends JsonResource { @@ -76,6 +77,34 @@ class EventResource extends JsonResource 'limits' => $eventPackage && $limitEvaluator ? $limitEvaluator->summarizeEventPackage($eventPackage) : null, + 'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [], ]; } + + protected function formatAddons(?\App\Models\EventPackage $eventPackage): array + { + if (! $eventPackage) { + return []; + } + + $addons = $eventPackage->relationLoaded('addons') + ? $eventPackage->addons + : $eventPackage->addons()->latest()->take(10)->get(); + + return $addons->map(function ($addon) { + return [ + 'id' => $addon->id, + 'key' => $addon->addon_key, + 'label' => $addon->metadata['label'] ?? null, + 'status' => $addon->status, + 'price_id' => $addon->price_id, + 'transaction_id' => $addon->transaction_id, + 'extra_photos' => (int) $addon->extra_photos, + 'extra_guests' => (int) $addon->extra_guests, + 'extra_gallery_days' => (int) $addon->extra_gallery_days, + 'purchased_at' => $addon->purchased_at?->toIso8601String(), + 'metadata' => Arr::only($addon->metadata ?? [], ['price_eur']), + ]; + })->all(); + } } diff --git a/app/Jobs/SyncPackageAddonToPaddle.php b/app/Jobs/SyncPackageAddonToPaddle.php new file mode 100644 index 0000000..f3deeac --- /dev/null +++ b/app/Jobs/SyncPackageAddonToPaddle.php @@ -0,0 +1,167 @@ +, price?: array} $options + */ + public function __construct(private readonly int $addonId, private readonly array $options = []) {} + + public function handle(PaddleAddonCatalogService $catalog): void + { + $addon = PackageAddon::query()->find($this->addonId); + + if (! $addon) { + return; + } + + $dryRun = (bool) ($this->options['dry_run'] ?? false); + $productOverrides = Arr::get($this->options, 'product', []); + $priceOverrides = Arr::get($this->options, 'price', []); + + if ($dryRun) { + $this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides); + + return; + } + + // Mark syncing (metadata) + $addon->forceFill([ + 'metadata' => array_merge($addon->metadata ?? [], [ + 'paddle_sync_status' => 'syncing', + 'paddle_synced_at' => now()->toIso8601String(), + ]), + ])->save(); + + try { + $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); + + $productResponse = $addon->metadata['paddle_product_id'] ?? null + ? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product']) + : $catalog->createProduct($addon, $payloadOverrides['product']); + + $productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null); + + if (! $productId) { + throw new PaddleException('Paddle product ID missing after addon sync.'); + } + + $priceResponse = $addon->price_id + ? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId])) + : $catalog->createPrice($addon, $productId, $payloadOverrides['price']); + + $priceId = (string) ($priceResponse['id'] ?? $addon->price_id); + + if (! $priceId) { + throw new PaddleException('Paddle price ID missing after addon sync.'); + } + + $addon->forceFill([ + 'price_id' => $priceId, + 'metadata' => array_merge($addon->metadata ?? [], [ + 'paddle_sync_status' => 'synced', + 'paddle_synced_at' => now()->toIso8601String(), + 'paddle_product_id' => $productId, + 'paddle_snapshot' => [ + 'product' => $productResponse, + 'price' => $priceResponse, + 'payload' => $payloadOverrides, + ], + ]), + ])->save(); + } catch (Throwable $exception) { + Log::error('Paddle addon sync failed', [ + 'addon_id' => $addon->id, + 'message' => $exception->getMessage(), + 'exception' => $exception, + ]); + + $addon->forceFill([ + 'metadata' => array_merge($addon->metadata ?? [], [ + 'paddle_sync_status' => 'failed', + 'paddle_synced_at' => now()->toIso8601String(), + 'paddle_error' => [ + 'message' => $exception->getMessage(), + 'class' => $exception::class, + ], + ]), + ])->save(); + + throw $exception; + } + } + + /** + * @param array $productOverrides + * @param array $priceOverrides + * @return array{product: array, price: array} + */ + protected function buildPayloadOverrides(PackageAddon $addon, array $productOverrides, array $priceOverrides): array + { + // Reuse Package model payload builder shape via duck typing + $baseProduct = [ + 'name' => $addon->label, + 'description' => sprintf('Addon %s', $addon->key), + 'type' => 'standard', + 'custom_data' => [ + 'addon_key' => $addon->key, + 'increments' => $addon->increments, + ], + ]; + + $basePrice = [ + 'description' => $addon->label, + 'custom_data' => [ + 'addon_key' => $addon->key, + ], + ]; + + return [ + 'product' => array_merge($baseProduct, $productOverrides), + 'price' => array_merge($basePrice, $priceOverrides), + ]; + } + + /** + * @param array $productOverrides + * @param array $priceOverrides + */ + protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void + { + $payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides); + + $addon->forceFill([ + 'metadata' => array_merge($addon->metadata ?? [], [ + 'paddle_sync_status' => 'dry-run', + 'paddle_synced_at' => now()->toIso8601String(), + 'paddle_snapshot' => [ + 'dry_run' => true, + 'payload' => $payloadOverrides, + ], + ]), + ])->save(); + + Log::info('Paddle addon dry-run snapshot generated', [ + 'addon_id' => $addon->id, + ]); + } +} diff --git a/app/Models/EventPackage.php b/app/Models/EventPackage.php index 189e125..335adcb 100644 --- a/app/Models/EventPackage.php +++ b/app/Models/EventPackage.php @@ -5,6 +5,7 @@ namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class EventPackage extends Model { @@ -20,6 +21,10 @@ class EventPackage extends Model 'used_photos', 'used_guests', 'gallery_expires_at', + 'limits_snapshot', + 'extra_photos', + 'extra_guests', + 'extra_gallery_days', ]; protected $casts = [ @@ -30,6 +35,10 @@ class EventPackage extends Model 'gallery_expired_notified_at' => 'datetime', 'used_photos' => 'integer', 'used_guests' => 'integer', + 'extra_photos' => 'integer', + 'extra_guests' => 'integer', + 'extra_gallery_days' => 'integer', + 'limits_snapshot' => 'array', ]; public function event(): BelongsTo @@ -42,6 +51,11 @@ class EventPackage extends Model return $this->belongsTo(Package::class)->withTrashed(); } + public function addons(): HasMany + { + return $this->hasMany(EventPackageAddon::class); + } + public function isActive(): bool { return $this->gallery_expires_at && $this->gallery_expires_at->isFuture(); @@ -53,7 +67,11 @@ class EventPackage extends Model return false; } - $maxPhotos = $this->package->max_photos ?? 0; + $maxPhotos = $this->effectiveLimits()['max_photos']; + + if ($maxPhotos === null) { + return true; + } return $this->used_photos < $maxPhotos; } @@ -64,23 +82,84 @@ class EventPackage extends Model return false; } - $maxGuests = $this->package->max_guests ?? 0; + $maxGuests = $this->effectiveLimits()['max_guests']; + + if ($maxGuests === null) { + return true; + } return $this->used_guests < $maxGuests; } public function getRemainingPhotosAttribute(): int { - $max = $this->package->max_photos ?? 0; + $limit = $this->effectiveLimits()['max_photos'] ?? 0; - return max(0, $max - $this->used_photos); + return max(0, (int) $limit - $this->used_photos); } public function getRemainingGuestsAttribute(): int { - $max = $this->package->max_guests ?? 0; + $limit = $this->effectiveLimits()['max_guests'] ?? 0; - return max(0, $max - $this->used_guests); + return max(0, (int) $limit - $this->used_guests); + } + + /** + * @return array{max_photos: ?int, max_guests: ?int, gallery_days: ?int, max_tasks: ?int, max_events_per_year: ?int} + */ + public function effectiveLimits(): array + { + $snapshot = is_array($this->limits_snapshot) ? $this->limits_snapshot : []; + + $base = [ + 'max_photos' => array_key_exists('max_photos', $snapshot) + ? $snapshot['max_photos'] + : ($this->package->max_photos ?? null), + 'max_guests' => array_key_exists('max_guests', $snapshot) + ? $snapshot['max_guests'] + : ($this->package->max_guests ?? null), + 'gallery_days' => array_key_exists('gallery_days', $snapshot) + ? $snapshot['gallery_days'] + : ($this->package->gallery_days ?? null), + 'max_tasks' => array_key_exists('max_tasks', $snapshot) + ? $snapshot['max_tasks'] + : ($this->package->max_tasks ?? null), + 'max_events_per_year' => array_key_exists('max_events_per_year', $snapshot) + ? $snapshot['max_events_per_year'] + : ($this->package->max_events_per_year ?? null), + ]; + + $applyExtra = static function (?int $limit, int $extra): ?int { + if ($limit === null) { + return null; + } + + $safeExtra = max(0, $extra); + + return max(0, $limit + $safeExtra); + }; + + $maxPhotos = $applyExtra($this->normalizeLimit($base['max_photos']), (int) ($this->extra_photos ?? 0)); + $maxGuests = $applyExtra($this->normalizeLimit($base['max_guests']), (int) ($this->extra_guests ?? 0)); + + return [ + 'max_photos' => $maxPhotos, + 'max_guests' => $maxGuests, + 'gallery_days' => $this->normalizeLimit($base['gallery_days']), + 'max_tasks' => $this->normalizeLimit($base['max_tasks']), + 'max_events_per_year' => $this->normalizeLimit($base['max_events_per_year']), + ]; + } + + public function effectivePhotoLimit(): ?int + { + return $this->effectiveLimits()['max_photos']; + } + + public function effectiveGuestLimit(): ?int + { + return $this->effectiveLimits()['max_guests']; } protected static function boot() @@ -95,6 +174,31 @@ class EventPackage extends Model $days = $eventPackage->package->gallery_days ?? 30; $eventPackage->gallery_expires_at = now()->addDays($days); } + + if (! $eventPackage->limits_snapshot) { + $package = $eventPackage->relationLoaded('package') + ? $eventPackage->package + : Package::query()->find($eventPackage->package_id); + + if ($package) { + $eventPackage->limits_snapshot = array_filter([ + 'max_photos' => $package->max_photos, + 'max_guests' => $package->max_guests, + 'gallery_days' => $package->gallery_days, + 'max_tasks' => $package->max_tasks, + 'max_events_per_year' => $package->max_events_per_year, + ], static fn ($value) => $value !== null); + } + } }); } + + private function normalizeLimit($value): ?int + { + if ($value === null) { + return null; + } + + return is_numeric($value) ? (int) $value : null; + } } diff --git a/app/Models/EventPackageAddon.php b/app/Models/EventPackageAddon.php new file mode 100644 index 0000000..5fa7235 --- /dev/null +++ b/app/Models/EventPackageAddon.php @@ -0,0 +1,66 @@ + 'array', + 'receipt_payload' => 'array', + 'purchased_at' => 'datetime', + ]; + + protected function increments(): Attribute + { + return Attribute::make( + get: fn () => [ + 'extra_photos' => (int) ($this->extra_photos ?? 0), + 'extra_guests' => (int) ($this->extra_guests ?? 0), + 'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0), + ], + ); + } + + public function eventPackage(): BelongsTo + { + return $this->belongsTo(EventPackage::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } +} diff --git a/app/Models/PackageAddon.php b/app/Models/PackageAddon.php new file mode 100644 index 0000000..dda8347 --- /dev/null +++ b/app/Models/PackageAddon.php @@ -0,0 +1,44 @@ + 'boolean', + 'metadata' => 'array', + 'extra_photos' => 'integer', + 'extra_guests' => 'integer', + 'extra_gallery_days' => 'integer', + 'sort' => 'integer', + ]; + + protected function increments(): Attribute + { + return Attribute::make( + get: fn () => [ + 'extra_photos' => (int) ($this->extra_photos ?? 0), + 'extra_guests' => (int) ($this->extra_guests ?? 0), + 'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0), + ], + ); + } +} diff --git a/app/Notifications/Addons/AddonPurchaseReceipt.php b/app/Notifications/Addons/AddonPurchaseReceipt.php new file mode 100644 index 0000000..4ef32fe --- /dev/null +++ b/app/Notifications/Addons/AddonPurchaseReceipt.php @@ -0,0 +1,47 @@ +addon->event; + $tenant = $event?->tenant; + $label = $this->addon->metadata['label'] ?? $this->addon->addon_key; + $amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null; + $currency = $this->addon->currency ?? 'EUR'; + $url = url('/tenant/events/'.($event?->slug ?? '')); + + return (new MailMessage) + ->subject(__('emails.addons.receipt.subject', ['addon' => $label])) + ->greeting(__('emails.addons.receipt.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')])) + ->line(__('emails.addons.receipt.body', [ + 'addon' => $label, + 'event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'), + 'amount' => $amount ? $amount.' '.$currency : __('emails.addons.receipt.unknown_amount'), + ])) + ->line(__('emails.addons.receipt.summary', [ + 'photos' => $this->addon->extra_photos, + 'guests' => $this->addon->extra_guests, + 'days' => $this->addon->extra_gallery_days, + ])) + ->action(__('emails.addons.receipt.action'), $url) + ->line(__('emails.package_limits.footer')); + } +} diff --git a/app/Notifications/Packages/EventPackageGuestLimitNotification.php b/app/Notifications/Packages/EventPackageGuestLimitNotification.php index 294a206..0d6bafb 100644 --- a/app/Notifications/Packages/EventPackageGuestLimitNotification.php +++ b/app/Notifications/Packages/EventPackageGuestLimitNotification.php @@ -43,6 +43,8 @@ class EventPackageGuestLimitNotification extends Notification implements ShouldQ 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), 'limit' => $this->limit, ])) + ->line(__('emails.package_limits.guest_limit.cta_addon')) + ->action(__('emails.package_limits.guest_limit.addon_action'), $url) ->action(__('emails.package_limits.guest_limit.action'), $url) ->line(__('emails.package_limits.footer')); } diff --git a/app/Notifications/Packages/EventPackagePhotoLimitNotification.php b/app/Notifications/Packages/EventPackagePhotoLimitNotification.php index 126919f..e76a492 100644 --- a/app/Notifications/Packages/EventPackagePhotoLimitNotification.php +++ b/app/Notifications/Packages/EventPackagePhotoLimitNotification.php @@ -43,6 +43,8 @@ class EventPackagePhotoLimitNotification extends Notification implements ShouldQ 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), 'limit' => $this->limit, ])) + ->line(__('emails.package_limits.photo_limit.cta_addon')) + ->action(__('emails.package_limits.photo_limit.addon_action'), $url) ->action(__('emails.package_limits.photo_limit.action'), $url) ->line(__('emails.package_limits.footer')); } diff --git a/app/Services/Addons/EventAddonCatalog.php b/app/Services/Addons/EventAddonCatalog.php new file mode 100644 index 0000000..ddedf07 --- /dev/null +++ b/app/Services/Addons/EventAddonCatalog.php @@ -0,0 +1,63 @@ + + */ + public function all(): array + { + $dbAddons = PackageAddon::query() + ->where('active', true) + ->orderBy('sort') + ->get() + ->mapWithKeys(function (PackageAddon $addon) { + return [$addon->key => [ + 'label' => $addon->label, + 'price_id' => $addon->price_id, + 'increments' => $addon->increments, + ]]; + }) + ->all(); + + // Fallback to config and merge (DB wins) + $configAddons = config('package-addons', []); + + return array_merge($configAddons, $dbAddons); + } + + /** + * @return array|null + */ + public function find(string $key): ?array + { + return $this->all()[$key] ?? null; + } + + public function resolvePriceId(string $key): ?string + { + $addon = $this->find($key); + + return $addon['price_id'] ?? null; + } + + /** + * @return array + */ + public function resolveIncrements(string $key): array + { + $addon = $this->find($key) ?? []; + + $increments = Arr::get($addon, 'increments', []); + + return collect($increments) + ->map(fn ($value) => is_numeric($value) ? (int) $value : 0) + ->filter(fn ($value) => $value > 0) + ->all(); + } +} diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php new file mode 100644 index 0000000..229ebd4 --- /dev/null +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -0,0 +1,112 @@ +catalog->find($addonKey)) { + throw ValidationException::withMessages([ + 'addon_key' => __('Unbekanntes Add-on.'), + ]); + } + + $priceId = $this->catalog->resolvePriceId($addonKey); + + if (! $priceId) { + throw ValidationException::withMessages([ + 'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'), + ]); + } + + $event->loadMissing('eventPackage'); + + if (! $event->eventPackage) { + throw ValidationException::withMessages([ + 'event' => __('Kein Paket für dieses Event hinterlegt.'), + ]); + } + + $addonIntent = (string) Str::uuid(); + + $increments = $this->catalog->resolveIncrements($addonKey); + + $metadata = [ + 'tenant_id' => (string) $tenant->id, + 'event_id' => (string) $event->id, + 'event_package_id' => (string) $event->eventPackage->id, + 'addon_key' => $addonKey, + 'addon_intent' => $addonIntent, + 'quantity' => $quantity, + ]; + + $requestPayload = array_filter([ + 'customer_id' => $tenant->paddle_customer_id, + 'items' => [ + [ + 'price_id' => $priceId, + 'quantity' => $quantity, + ], + ], + 'metadata' => $metadata, + 'success_url' => $payload['success_url'] ?? null, + 'cancel_url' => $payload['cancel_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + $response = $this->paddle->post('/checkout/links', $requestPayload); + + $checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url'); + $checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id'); + + if (! $checkoutUrl) { + Log::warning('Paddle addon checkout response missing url', ['response' => $response]); + } + + EventPackageAddon::create([ + 'event_package_id' => $event->eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $tenant->id, + 'addon_key' => $addonKey, + 'quantity' => $quantity, + 'price_id' => $priceId, + 'checkout_id' => $checkoutId, + 'transaction_id' => null, + 'status' => 'pending', + 'metadata' => array_merge($metadata, [ + 'increments' => $increments, + 'provider_payload' => $response, + ]), + 'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity, + 'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity, + 'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity, + ]); + + return [ + 'checkout_url' => $checkoutUrl, + 'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'), + 'id' => $checkoutId, + ]; + } +} diff --git a/app/Services/Addons/EventAddonWebhookService.php b/app/Services/Addons/EventAddonWebhookService.php new file mode 100644 index 0000000..baf31db --- /dev/null +++ b/app/Services/Addons/EventAddonWebhookService.php @@ -0,0 +1,125 @@ +extractMetadata($data); + $intentId = $metadata['addon_intent'] ?? null; + $addonKey = $metadata['addon_key'] ?? null; + + if (! $intentId || ! $addonKey) { + return false; + } + + $transactionId = $data['id'] ?? $data['transaction_id'] ?? null; + $checkoutId = $data['checkout_id'] ?? null; + + $addon = EventPackageAddon::query() + ->where('addon_key', $addonKey) + ->where(function ($query) use ($intentId, $checkoutId, $transactionId) { + $query->where('metadata->addon_intent', $intentId) + ->orWhere('checkout_id', $checkoutId) + ->orWhere('transaction_id', $transactionId); + }) + ->latest('id') + ->first(); + + if (! $addon) { + Log::info('[AddonWebhook] Add-on intent not found', [ + 'intent' => $intentId, + 'addon_key' => $addonKey, + 'transaction_id' => $transactionId, + ]); + + return false; + } + + if ($addon->status === 'completed') { + return true; // idempotent + } + + $increments = $this->catalog->resolveIncrements($addonKey); + + DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) { + $addon->forceFill([ + 'transaction_id' => $transactionId, + 'checkout_id' => $addon->checkout_id ?: $checkoutId, + 'status' => 'completed', + 'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'), + 'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'), + 'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]), + 'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null, + 'purchased_at' => now(), + ])->save(); + + /** @var EventPackage $eventPackage */ + $eventPackage = EventPackage::query() + ->lockForUpdate() + ->find($addon->event_package_id); + + if (! $eventPackage) { + return; + } + + $eventPackage->forceFill([ + 'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($increments['extra_photos'] ?? 0) * $addon->quantity, + 'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($increments['extra_guests'] ?? 0) * $addon->quantity, + 'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity, + ]); + + if (($increments['extra_gallery_days'] ?? 0) > 0) { + $base = $eventPackage->gallery_expires_at ?? now(); + $eventPackage->gallery_expires_at = $base->copy()->addDays((int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity); + } + + $eventPackage->save(); + + $tenant = $addon->tenant; + if ($tenant) { + Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email]) + ->notify(new AddonPurchaseReceipt($addon)); + } + }); + + return true; + } + + /** + * @param array $data + * @return array + */ + private function extractMetadata(array $data): array + { + $metadata = []; + + if (isset($data['metadata']) && is_array($data['metadata'])) { + $metadata = $data['metadata']; + } + + if (isset($data['custom_data']) && is_array($data['custom_data'])) { + $metadata = array_merge($metadata, $data['custom_data']); + } + + return $metadata; + } +} diff --git a/app/Services/Packages/PackageLimitEvaluator.php b/app/Services/Packages/PackageLimitEvaluator.php index 9405eb8..1d30942 100644 --- a/app/Services/Packages/PackageLimitEvaluator.php +++ b/app/Services/Packages/PackageLimitEvaluator.php @@ -77,7 +77,7 @@ class PackageLimitEvaluator ]; } - $maxPhotos = $eventPackage->package->max_photos; + $maxPhotos = $eventPackage->effectivePhotoLimit(); if ($maxPhotos === null) { return null; @@ -115,17 +115,17 @@ class PackageLimitEvaluator public function summarizeEventPackage(EventPackage $eventPackage): array { - $package = $eventPackage->package; + $limits = $eventPackage->effectiveLimits(); $photoSummary = $this->buildUsageSummary( (int) $eventPackage->used_photos, - $package?->max_photos, + $limits['max_photos'], config('package-limits.photo_thresholds', []) ); $guestSummary = $this->buildUsageSummary( (int) $eventPackage->used_guests, - $package?->max_guests, + $limits['max_guests'], config('package-limits.guest_thresholds', []) ); diff --git a/app/Services/Packages/PackageUsageTracker.php b/app/Services/Packages/PackageUsageTracker.php index c2c73ca..7609940 100644 --- a/app/Services/Packages/PackageUsageTracker.php +++ b/app/Services/Packages/PackageUsageTracker.php @@ -15,7 +15,7 @@ class PackageUsageTracker public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void { - $limit = $eventPackage->package?->max_photos; + $limit = $eventPackage->effectivePhotoLimit(); if ($limit === null || $limit <= 0) { return; @@ -51,7 +51,7 @@ class PackageUsageTracker public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void { - $limit = $eventPackage->package?->max_guests; + $limit = $eventPackage->effectiveGuestLimit(); if ($limit === null || $limit <= 0) { return; diff --git a/app/Services/Paddle/PaddleAddonCatalogService.php b/app/Services/Paddle/PaddleAddonCatalogService.php new file mode 100644 index 0000000..1c66df6 --- /dev/null +++ b/app/Services/Paddle/PaddleAddonCatalogService.php @@ -0,0 +1,137 @@ + + */ + public function createProduct(PackageAddon $addon, array $overrides = []): array + { + $payload = $this->buildProductPayload($addon, $overrides); + + return $this->extractEntity($this->client->post('/products', $payload)); + } + + /** + * @return array + */ + public function updateProduct(string $productId, PackageAddon $addon, array $overrides = []): array + { + $payload = $this->buildProductPayload($addon, $overrides); + + return $this->extractEntity($this->client->patch("/products/{$productId}", $payload)); + } + + /** + * @return array + */ + public function createPrice(PackageAddon $addon, string $productId, array $overrides = []): array + { + $payload = $this->buildPricePayload($addon, $productId, $overrides); + + return $this->extractEntity($this->client->post('/prices', $payload)); + } + + /** + * @return array + */ + public function updatePrice(string $priceId, PackageAddon $addon, array $overrides = []): array + { + $payload = $this->buildPricePayload($addon, $overrides['product_id'] ?? '', $overrides); + + return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload)); + } + + /** + * @return array + */ + public function buildProductPayload(PackageAddon $addon, array $overrides = []): array + { + $payload = array_merge([ + 'name' => $overrides['name'] ?? $addon->label, + 'description' => $overrides['description'] ?? sprintf('Fotospiel Add-on: %s', $addon->label), + 'tax_category' => $overrides['tax_category'] ?? 'standard', + 'type' => $overrides['type'] ?? 'standard', + 'custom_data' => array_merge([ + 'addon_key' => $addon->key, + 'increments' => $addon->increments, + ], $overrides['custom_data'] ?? []), + ], Arr::except($overrides, ['tax_category', 'type', 'custom_data', 'name', 'description'])); + + return $this->cleanPayload($payload); + } + + /** + * @return array + */ + public function buildPricePayload(PackageAddon $addon, string $productId, array $overrides = []): array + { + $unitPrice = $overrides['unit_price'] ?? $this->defaultUnitPrice($addon); + + $payload = array_merge([ + 'product_id' => $productId, + 'description' => $overrides['description'] ?? $addon->label, + 'unit_price' => $unitPrice, + 'custom_data' => array_merge([ + 'addon_key' => $addon->key, + ], $overrides['custom_data'] ?? []), + ], Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id'])); + + return $this->cleanPayload($payload); + } + + /** + * @param array $payload + * @return array + */ + protected function extractEntity(array $payload): array + { + return Arr::get($payload, 'data', $payload); + } + + /** + * @param array $payload + * @return array + */ + protected function cleanPayload(array $payload): array + { + $filtered = collect($payload) + ->reject(static fn ($value) => $value === null || $value === '' || $value === []) + ->all(); + + if (array_key_exists('custom_data', $filtered)) { + $filtered['custom_data'] = collect($filtered['custom_data']) + ->reject(static fn ($value) => $value === null || $value === '' || $value === []) + ->all(); + } + + return $filtered; + } + + /** + * @return array + */ + protected function defaultUnitPrice(PackageAddon $addon): array + { + $metaPrice = $addon->metadata['price_eur'] ?? null; + + if (! is_numeric($metaPrice)) { + throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.'); + } + + $amountCents = (int) round(((float) $metaPrice) * 100); + + return [ + 'amount' => (string) $amountCents, + 'currency_code' => 'EUR', + ]; + } +} diff --git a/app/Services/Photobooth/PhotoboothIngestService.php b/app/Services/Photobooth/PhotoboothIngestService.php index 897e25e..815a3ce 100644 --- a/app/Services/Photobooth/PhotoboothIngestService.php +++ b/app/Services/Photobooth/PhotoboothIngestService.php @@ -239,11 +239,13 @@ class PhotoboothIngestService return false; } - $limit = $eventPackage->package->max_photos; + $limit = $eventPackage->effectivePhotoLimit(); - return $limit !== null - && $limit > 0 - && $eventPackage->used_photos >= $limit; + if ($limit === null) { + return false; + } + + return $limit > 0 && $eventPackage->used_photos >= $limit; } protected function resolveEmotionId(Event $event): ?int diff --git a/app/Testing/Mailbox.php b/app/Testing/Mailbox.php index c7db3e1..aa277f7 100644 --- a/app/Testing/Mailbox.php +++ b/app/Testing/Mailbox.php @@ -29,7 +29,9 @@ class Mailbox 'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null, 'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null, 'sent_at' => now()->toIso8601String(), - 'headers' => (string) $event->message->getHeaders(), + 'headers' => method_exists($event->message, 'getHeaders') && method_exists($event->message->getHeaders(), 'toString') + ? $event->message->getHeaders()->toString() + : null, ]; self::write($messages); diff --git a/config/package-addons.php b/config/package-addons.php new file mode 100644 index 0000000..b620b6c --- /dev/null +++ b/config/package-addons.php @@ -0,0 +1,33 @@ + [ + 'label' => 'Extra photos (500)', + 'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_SMALL_PRICE_ID'), + 'increments' => [ + 'extra_photos' => 500, + ], + ], + 'extra_photos_large' => [ + 'label' => 'Extra photos (2,000)', + 'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_LARGE_PRICE_ID'), + 'increments' => [ + 'extra_photos' => 2000, + ], + ], + 'extra_guests' => [ + 'label' => 'Extra guests (100)', + 'price_id' => env('PADDLE_ADDON_EXTRA_GUESTS_PRICE_ID'), + 'increments' => [ + 'extra_guests' => 100, + ], + ], + 'extend_gallery_30d' => [ + 'label' => 'Gallery extension (30 days)', + 'price_id' => env('PADDLE_ADDON_EXTEND_GALLERY_30D_PRICE_ID'), + 'increments' => [ + 'extra_gallery_days' => 30, + ], + ], +]; diff --git a/database/migrations/2025_10_30_000000_add_limits_snapshot_and_extras_to_event_packages_table.php b/database/migrations/2025_10_30_000000_add_limits_snapshot_and_extras_to_event_packages_table.php new file mode 100644 index 0000000..3863608 --- /dev/null +++ b/database/migrations/2025_10_30_000000_add_limits_snapshot_and_extras_to_event_packages_table.php @@ -0,0 +1,88 @@ +json('limits_snapshot')->nullable()->after('package_id'); + } + + if (! Schema::hasColumn('event_packages', 'extra_photos')) { + $table->integer('extra_photos')->default(0)->after('used_photos'); + } + + if (! Schema::hasColumn('event_packages', 'extra_guests')) { + $table->integer('extra_guests')->default(0)->after('used_guests'); + } + + if (! Schema::hasColumn('event_packages', 'extra_gallery_days')) { + $table->integer('extra_gallery_days')->default(0)->after('gallery_expires_at'); + } + }); + + // Backfill snapshots from current package limits where missing. + if (Schema::hasTable('event_packages') && Schema::hasTable('packages')) { + DB::table('event_packages') + ->whereNull('limits_snapshot') + ->orderBy('id') + ->chunkById(200, function ($rows) { + foreach ($rows as $row) { + $package = DB::table('packages') + ->where('id', $row->package_id) + ->first([ + 'max_photos', + 'max_guests', + 'gallery_days', + 'max_tasks', + 'max_events_per_year', + ]); + + if (! $package) { + continue; + } + + $snapshot = array_filter([ + 'max_photos' => $package->max_photos, + 'max_guests' => $package->max_guests, + 'gallery_days' => $package->gallery_days, + 'max_tasks' => $package->max_tasks, + 'max_events_per_year' => $package->max_events_per_year, + ], fn ($value) => $value !== null); + + if ($snapshot === []) { + continue; + } + + DB::table('event_packages') + ->where('id', $row->id) + ->update(['limits_snapshot' => json_encode($snapshot)]); + } + }); + } + } + + public function down(): void + { + Schema::table('event_packages', function (Blueprint $table) { + if (Schema::hasColumn('event_packages', 'limits_snapshot')) { + $table->dropColumn('limits_snapshot'); + } + if (Schema::hasColumn('event_packages', 'extra_photos')) { + $table->dropColumn('extra_photos'); + } + if (Schema::hasColumn('event_packages', 'extra_guests')) { + $table->dropColumn('extra_guests'); + } + if (Schema::hasColumn('event_packages', 'extra_gallery_days')) { + $table->dropColumn('extra_gallery_days'); + } + }); + } +}; diff --git a/database/migrations/2025_10_30_110000_create_event_package_addons_table.php b/database/migrations/2025_10_30_110000_create_event_package_addons_table.php new file mode 100644 index 0000000..9633788 --- /dev/null +++ b/database/migrations/2025_10_30_110000_create_event_package_addons_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('event_package_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->string('addon_key'); + $table->integer('quantity')->default(1); + $table->integer('extra_photos')->default(0); + $table->integer('extra_guests')->default(0); + $table->integer('extra_gallery_days')->default(0); + $table->string('price_id')->nullable(); + $table->string('checkout_id')->nullable(); + $table->string('transaction_id')->nullable(); + $table->enum('status', ['pending', 'completed', 'failed'])->default('pending'); + $table->decimal('amount', 10, 2)->nullable(); + $table->string('currency', 3)->nullable(); + $table->json('metadata')->nullable(); + $table->json('receipt_payload')->nullable(); + $table->string('error')->nullable(); + $table->timestamp('purchased_at')->nullable(); + $table->timestamps(); + + $table->index(['tenant_id', 'event_id']); + $table->index(['checkout_id']); + $table->index(['transaction_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('event_package_addons'); + } +}; diff --git a/database/migrations/2025_10_31_000000_create_package_addons_table.php b/database/migrations/2025_10_31_000000_create_package_addons_table.php new file mode 100644 index 0000000..0daf918 --- /dev/null +++ b/database/migrations/2025_10_31_000000_create_package_addons_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('key')->unique(); + $table->string('label'); + $table->string('price_id')->nullable(); + $table->integer('extra_photos')->default(0); + $table->integer('extra_guests')->default(0); + $table->integer('extra_gallery_days')->default(0); + $table->boolean('active')->default(true); + $table->unsignedInteger('sort')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('package_addons'); + } +}; diff --git a/database/migrations/2025_11_21_104052_add_receipt_payload_to_event_package_addons_table.php b/database/migrations/2025_11_21_104052_add_receipt_payload_to_event_package_addons_table.php new file mode 100644 index 0000000..0520562 --- /dev/null +++ b/database/migrations/2025_11_21_104052_add_receipt_payload_to_event_package_addons_table.php @@ -0,0 +1,32 @@ +json('receipt_payload')->nullable()->after('metadata'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('event_package_addons', function (Blueprint $table) { + if (Schema::hasColumn('event_package_addons', 'receipt_payload')) { + $table->dropColumn('receipt_payload'); + } + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index a327a3c..a197c4c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder MediaStorageTargetSeeder::class, LegalPagesSeeder::class, PackageSeeder::class, + PackageAddonSeeder::class, EventTypesSeeder::class, EmotionsSeeder::class, TaskCollectionsSeeder::class, diff --git a/database/seeders/PackageAddonSeeder.php b/database/seeders/PackageAddonSeeder.php new file mode 100644 index 0000000..18e5761 --- /dev/null +++ b/database/seeders/PackageAddonSeeder.php @@ -0,0 +1,143 @@ + 'extra_photos_500', + 'label' => '+500 Fotos', + 'price_id' => null, + 'extra_photos' => 500, + 'extra_guests' => 0, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 10, + 'metadata' => ['price_eur' => 5], + ], + [ + 'key' => 'extra_photos_2000', + 'label' => '+2.000 Fotos', + 'price_id' => null, + 'extra_photos' => 2000, + 'extra_guests' => 0, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 11, + 'metadata' => ['price_eur' => 12], + ], + [ + 'key' => 'extra_photos_5000', + 'label' => '+5.000 Fotos', + 'price_id' => null, + 'extra_photos' => 5000, + 'extra_guests' => 0, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 12, + 'metadata' => ['price_eur' => 25], + ], + [ + 'key' => 'extra_guests_50', + 'label' => '+50 Gäste', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 50, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 18, + 'metadata' => ['price_eur' => 3], + ], + [ + 'key' => 'extra_guests_100', + 'label' => '+100 Gäste', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 100, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 19, + 'metadata' => ['price_eur' => 5], + ], + [ + 'key' => 'extra_guests_300', + 'label' => '+300 Gäste', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 300, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 20, + 'metadata' => ['price_eur' => 12], + ], + [ + 'key' => 'extend_gallery_30d', + 'label' => 'Galerie +30 Tage', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 0, + 'extra_gallery_days' => 30, + 'active' => true, + 'sort' => 30, + 'metadata' => ['price_eur' => 4], + ], + [ + 'key' => 'extend_gallery_90d', + 'label' => 'Galerie +90 Tage', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 0, + 'extra_gallery_days' => 90, + 'active' => true, + 'sort' => 31, + 'metadata' => ['price_eur' => 10], + ], + [ + 'key' => 'extend_gallery_180d', + 'label' => 'Galerie +180 Tage', + 'price_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 0, + 'extra_gallery_days' => 180, + 'active' => true, + 'sort' => 32, + 'metadata' => ['price_eur' => 20], + ], + [ + 'key' => 'event_boost_medium', + 'label' => 'Event-Boost (100 Gäste, 2.000 Fotos, +30 Tage)', + 'price_id' => null, + 'extra_photos' => 2000, + 'extra_guests' => 100, + 'extra_gallery_days' => 30, + 'active' => true, + 'sort' => 40, + 'metadata' => ['price_eur' => 18], + ], + [ + 'key' => 'event_boost_large', + 'label' => 'Event-Boost (300 Gäste, 5.000 Fotos, +90 Tage)', + 'price_id' => null, + 'extra_photos' => 5000, + 'extra_guests' => 300, + 'extra_gallery_days' => 90, + 'active' => true, + 'sort' => 41, + 'metadata' => ['price_eur' => 38], + ], + ]; + + foreach ($addons as $addon) { + PackageAddon::updateOrCreate( + ['key' => $addon['key']], + $addon, + ); + } + } +} diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index b26c1c7..a3c97f5 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -82,6 +82,7 @@ export type TenantEvent = { expires_at: string | null; } | null; limits?: EventLimitSummary | null; + addons?: EventAddonSummary[]; [key: string]: unknown; }; @@ -156,6 +157,32 @@ export type PhotoboothStatus = { }; }; +export type EventAddonCheckout = { + addon_key: string; + quantity?: number; + checkout_url: string | null; + checkout_id: string | null; + expires_at: string | null; +}; + +export type EventAddonCatalogItem = { + key: string; + label: string; + price_id: string | null; + increments?: Record; +}; + +export type EventAddonSummary = { + id: number; + key: string; + label?: string | null; + status: 'pending' | 'completed' | 'failed'; + extra_photos: number; + extra_guests: number; + extra_gallery_days: number; + purchased_at: string | null; +}; + export type HelpCenterArticleSummary = { slug: string; title: string; @@ -338,6 +365,28 @@ export type PaddleTransactionSummary = { tax?: number | null; }; +export type TenantAddonEventSummary = { + id: number; + slug: string; + name: string | Record | null; +}; + +export type TenantAddonHistoryEntry = { + id: number; + addon_key: string; + label?: string | null; + event: TenantAddonEventSummary | null; + amount: number | null; + currency: string | null; + status: 'pending' | 'completed' | 'failed'; + purchased_at: string | null; + extra_photos: number; + extra_guests: number; + extra_gallery_days: number; + quantity: number; + receipt_url?: string | null; +}; + export type CreditLedgerEntry = { id: number; delta: number; @@ -829,6 +878,48 @@ function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary }; } +function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry { + let event: TenantAddonEventSummary | null = null; + + if (entry.event && typeof entry.event === 'object') { + const rawEvent = entry.event as JsonValue; + const id = Number((rawEvent as { id?: unknown }).id ?? 0); + const slugValue = (rawEvent as { slug?: unknown }).slug; + const rawName = (rawEvent as { name?: unknown }).name ?? null; + let name: TenantAddonEventSummary['name'] = null; + + if (typeof rawName === 'string') { + name = rawName; + } else if (rawName && typeof rawName === 'object') { + name = normalizeTranslationMap(rawName, undefined, true); + } + + event = { + id, + slug: typeof slugValue === 'string' ? slugValue : '', + name, + }; + } + + const amountValue = entry.amount; + + return { + id: Number(entry.id ?? 0), + addon_key: String(entry.addon_key ?? ''), + label: typeof entry.label === 'string' ? entry.label : null, + event, + amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null, + currency: typeof entry.currency === 'string' ? entry.currency : null, + status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending', + purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null, + extra_photos: Number(entry.extra_photos ?? 0), + extra_guests: Number(entry.extra_guests ?? 0), + extra_gallery_days: Number(entry.extra_gallery_days ?? 0), + quantity: Number(entry.quantity ?? 1), + receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : null, + }; +} + function normalizeTask(task: JsonValue): TenantTask { const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); @@ -1122,6 +1213,28 @@ export async function getEvent(slug: string): Promise { return normalizeEvent(data.data); } +export async function createEventAddonCheckout( + eventSlug: string, + params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string } +): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> { + const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(params), + }); + + return await jsonOrThrow<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }>( + response, + 'Failed to create addon checkout' + ); +} + +export async function getAddonCatalog(): Promise { + const response = await authorizedFetch('/api/v1/tenant/addons/catalog'); + const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons'); + return data.data ?? []; +} + export async function getEventTypes(): Promise { const response = await authorizedFetch('/api/v1/tenant/event-types'); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types'); @@ -1675,6 +1788,42 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{ }; } +export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{ + data: TenantAddonHistoryEntry[]; + meta: PaginationMeta; +}> { + const params = new URLSearchParams({ + page: String(Math.max(1, page)), + per_page: String(Math.max(1, Math.min(perPage, 100))), + }); + + const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`); + + if (response.status === 404) { + return { + data: [], + meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 }, + }; + } + + const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial; current_page?: number; last_page?: number; per_page?: number; total?: number }>( + response, + 'Failed to load add-on history' + ); + + const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : []; + const metaSource = payload.meta ?? payload; + + const meta: PaginationMeta = { + current_page: Number(metaSource.current_page ?? 1), + last_page: Number(metaSource.last_page ?? 1), + per_page: Number(metaSource.per_page ?? perPage), + total: Number(metaSource.total ?? rows.length), + }; + + return { data: rows, meta }; +} + export async function getCreditBalance(): Promise { const response = await authorizedFetch('/api/v1/tenant/credits/balance'); if (response.status === 404) { diff --git a/resources/js/admin/components/Addons/AddonSummaryList.tsx b/resources/js/admin/components/Addons/AddonSummaryList.tsx new file mode 100644 index 0000000..cc2e844 --- /dev/null +++ b/resources/js/admin/components/Addons/AddonSummaryList.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { Badge } from '@/components/ui/badge'; +import type { EventAddonSummary } from '../../api'; + +type Props = { + addons: EventAddonSummary[]; + t: (key: string, fallback: string) => string; +}; + +export function AddonSummaryList({ addons, t }: Props) { + if (!addons.length) { + return null; + } + + return ( +
+ {addons.map((addon) => ( +
+
+

{addon.label ?? addon.key}

+

+ {buildSummary(addon, t)} +

+ {addon.purchased_at ? ( +

+ {t('events.sections.addons.purchasedAt', `Purchased ${new Date(addon.purchased_at).toLocaleString()}`, { + date: new Date(addon.purchased_at).toLocaleString(), + })} +

+ ) : null} +
+ + {t(`events.sections.addons.status.${addon.status}`, addon.status)} + +
+ ))} +
+ ); +} + +function buildSummary(addon: EventAddonSummary, t: (key: string, fallback: string, options?: Record) => string): string { + const parts: string[] = []; + if (addon.extra_photos > 0) { + parts.push( + t('events.sections.addons.summary.photos', `+${addon.extra_photos} photos`, { + count: addon.extra_photos.toLocaleString(), + }), + ); + } + if (addon.extra_guests > 0) { + parts.push( + t('events.sections.addons.summary.guests', `+${addon.extra_guests} guests`, { + count: addon.extra_guests.toLocaleString(), + }), + ); + } + if (addon.extra_gallery_days > 0) { + parts.push( + t('events.sections.addons.summary.gallery', `+${addon.extra_gallery_days} days gallery`, { + count: addon.extra_gallery_days, + }), + ); + } + + return parts.join(' · '); +} diff --git a/resources/js/admin/components/Addons/AddonsPicker.tsx b/resources/js/admin/components/Addons/AddonsPicker.tsx new file mode 100644 index 0000000..1fd270f --- /dev/null +++ b/resources/js/admin/components/Addons/AddonsPicker.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { ShoppingCart } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { EventAddonCatalogItem } from '../../api'; + +type Props = { + addons: EventAddonCatalogItem[]; + scope: 'photos' | 'guests' | 'gallery'; + onCheckout: (addonKey: string) => void; + busy?: boolean; + t: (key: string, fallback: string) => string; +}; + +const scopeDefaults: Record = { + photos: ['extra_photos_500', 'extra_photos_2000'], + guests: ['extra_guests_50', 'extra_guests_100'], + gallery: ['extend_gallery_30d', 'extend_gallery_90d'], +}; + +export function AddonsPicker({ addons, scope, onCheckout, busy, t }: Props) { + const options = React.useMemo(() => { + const whitelist = scopeDefaults[scope]; + const filtered = addons.filter((addon) => whitelist.includes(addon.key)); + return filtered.length ? filtered : addons; + }, [addons, scope]); + + const [selected, setSelected] = React.useState(() => options[0]?.key); + + React.useEffect(() => { + setSelected(options[0]?.key); + }, [options]); + + if (!options.length) { + return null; + } + + return ( +
+ + +
+ ); +} diff --git a/resources/js/admin/i18n/locales/de/common.json b/resources/js/admin/i18n/locales/de/common.json index 71becab..d11bc03 100644 --- a/resources/js/admin/i18n/locales/de/common.json +++ b/resources/js/admin/i18n/locales/de/common.json @@ -72,6 +72,9 @@ "galleryWarningDay": "Galerie läuft in {days} Tag ab.", "galleryWarningDays": "Galerie läuft in {days} Tagen ab.", "galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.", - "unlimited": "Unbegrenzt" + "unlimited": "Unbegrenzt", + "buyMorePhotos": "Mehr Fotos freischalten", + "buyMoreGuests": "Mehr Gäste freischalten", + "extendGallery": "Galerie verlängern" } } diff --git a/resources/js/admin/i18n/locales/de/management.json b/resources/js/admin/i18n/locales/de/management.json index 78f01bc..ab90d06 100644 --- a/resources/js/admin/i18n/locales/de/management.json +++ b/resources/js/admin/i18n/locales/de/management.json @@ -80,6 +80,32 @@ "loadingMore": "Laden…" } }, + "addOns": { + "title": "Add-on-Verlauf", + "description": "Einmalige Add-ons, die für diesen Tenant gebucht wurden.", + "empty": "Noch keine Add-ons gebucht.", + "badge": "Add-ons", + "table": { + "addon": "Add-on", + "event": "Event", + "amount": "Betrag", + "status": "Status", + "purchased": "Gekauft", + "eventFallback": "Event archiviert" + }, + "status": { + "pending": "In Bearbeitung", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen" + }, + "extras": { + "photos": "+{{count}} Fotos", + "guests": "+{{count}} Gäste", + "gallery": "+{{count}} Galerietage" + }, + "loadMore": "Weitere Add-ons laden", + "loadingMore": "Add-ons werden geladen…" + }, "packages": { "title": "Paket-Historie", "description": "Übersicht über aktive und vergangene Pakete.", @@ -382,7 +408,8 @@ "backToEvent": "Event öffnen", "copy": "Link kopieren", "copied": "Kopiert!", - "deactivate": "Deaktivieren" + "deactivate": "Deaktivieren", + "buyMoreGuests": "Mehr Gäste freischalten" }, "labels": { "usage": "Nutzung", @@ -511,11 +538,16 @@ "loadFailed": "Event konnte nicht geladen werden.", "notFoundTitle": "Event nicht gefunden", "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.", - "toggleFailed": "Status konnte nicht angepasst werden." + "toggleFailed": "Status konnte nicht angepasst werden.", + "checkoutMissing": "Checkout konnte nicht gestartet werden.", + "checkoutFailed": "Add-on Checkout fehlgeschlagen." }, "alerts": { "failedTitle": "Aktion fehlgeschlagen" }, + "success": { + "addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze." + }, "placeholders": { "untitled": "Unbenanntes Event" }, @@ -526,7 +558,10 @@ "tasks": "Aufgaben verwalten", "invites": "Einladungen & Layouts", "photos": "Fotos moderieren", - "refresh": "Aktualisieren" + "refresh": "Aktualisieren", + "buyMorePhotos": "Mehr Fotos freischalten", + "buyMoreGuests": "Mehr Gäste freischalten", + "extendGallery": "Galerie verlängern" }, "workspace": { "detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.", @@ -552,6 +587,23 @@ "activeYes": "Ja", "activeNo": "Nein" }, + "sections": { + "addons": { + "title": "Add-ons & Upgrades", + "description": "Zuletzt gebuchte Add-ons für dieses Event.", + "status": { + "completed": "Aktiv", + "pending": "In Bearbeitung", + "failed": "Fehlgeschlagen" + }, + "purchasedAt": "Gekauft {{date}}", + "summary": { + "photos": "+{{count}} Fotos", + "guests": "+{{count}} Gäste", + "gallery": "+{{count}} Tage Galerie" + } + } + }, "status": { "published": "Veröffentlicht", "draft": "Entwurf", diff --git a/resources/js/admin/i18n/locales/en/common.json b/resources/js/admin/i18n/locales/en/common.json index 8bc277c..49db6af 100644 --- a/resources/js/admin/i18n/locales/en/common.json +++ b/resources/js/admin/i18n/locales/en/common.json @@ -72,6 +72,9 @@ "galleryWarningDay": "Gallery expires in {days} day.", "galleryWarningDays": "Gallery expires in {days} days.", "galleryExpired": "Gallery has expired. Guests can no longer access the photos.", - "unlimited": "Unlimited" + "unlimited": "Unlimited", + "buyMorePhotos": "Unlock more photos", + "buyMoreGuests": "Unlock more guests", + "extendGallery": "Extend gallery" } } diff --git a/resources/js/admin/i18n/locales/en/management.json b/resources/js/admin/i18n/locales/en/management.json index 32f2f02..249d115 100644 --- a/resources/js/admin/i18n/locales/en/management.json +++ b/resources/js/admin/i18n/locales/en/management.json @@ -80,6 +80,32 @@ "loadingMore": "Loading…" } }, + "addOns": { + "title": "Add-on history", + "description": "One-time add-ons purchased for this tenant.", + "empty": "No add-ons purchased yet.", + "badge": "Add-ons", + "table": { + "addon": "Add-on", + "event": "Event", + "amount": "Amount", + "status": "Status", + "purchased": "Purchased", + "eventFallback": "Event archived" + }, + "status": { + "pending": "Processing", + "completed": "Completed", + "failed": "Failed" + }, + "extras": { + "photos": "+{{count}} photos", + "guests": "+{{count}} guests", + "gallery": "+{{count}} gallery days" + }, + "loadMore": "Load more add-ons", + "loadingMore": "Loading add-ons…" + }, "packages": { "title": "Package history", "description": "Overview of current and past packages.", @@ -382,7 +408,8 @@ "backToEvent": "Open event", "copy": "Copy link", "copied": "Copied!", - "deactivate": "Deactivate" + "deactivate": "Deactivate", + "buyMoreGuests": "Unlock more guests" }, "labels": { "usage": "Usage", @@ -511,11 +538,16 @@ "loadFailed": "Event could not be loaded.", "notFoundTitle": "Event not found", "notFoundBody": "Without a valid identifier we can’t load the data. Return to the list and choose an event.", - "toggleFailed": "Status could not be updated." + "toggleFailed": "Status could not be updated.", + "checkoutMissing": "Checkout could not be started.", + "checkoutFailed": "Add-on checkout failed." }, "alerts": { "failedTitle": "Action failed" }, + "success": { + "addonApplied": "Add-on applied. Limits will refresh shortly." + }, "placeholders": { "untitled": "Untitled event" }, @@ -526,7 +558,10 @@ "tasks": "Manage tasks", "invites": "Invites & layouts", "photos": "Moderate photos", - "refresh": "Refresh" + "refresh": "Refresh", + "buyMorePhotos": "Unlock more photos", + "buyMoreGuests": "Unlock more guests", + "extendGallery": "Extend gallery" }, "workspace": { "detailSubtitle": "Keep status, tasks, and invites of your event in one view.", @@ -552,6 +587,23 @@ "activeYes": "Yes", "activeNo": "No" }, + "sections": { + "addons": { + "title": "Add-ons & Boosts", + "description": "Recently purchased add-ons for this event.", + "status": { + "completed": "Active", + "pending": "Processing", + "failed": "Failed" + }, + "purchasedAt": "Purchased {{date}}", + "summary": { + "photos": "+{{count}} photos", + "guests": "+{{count}} guests", + "gallery": "+{{count}} days gallery" + } + } + }, "status": { "published": "Published", "draft": "Draft", diff --git a/resources/js/admin/pages/BillingPage.tsx b/resources/js/admin/pages/BillingPage.tsx index 72df793..853e176 100644 --- a/resources/js/admin/pages/BillingPage.tsx +++ b/resources/js/admin/pages/BillingPage.tsx @@ -8,7 +8,15 @@ import { Button } from '@/components/ui/button'; import { Separator } from '@/components/ui/separator'; import { AdminLayout } from '../components/AdminLayout'; -import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api'; +import { + getTenantPackagesOverview, + getTenantPaddleTransactions, + getTenantAddonHistory, + PaddleTransactionSummary, + TenantAddonHistoryEntry, + TenantPackageSummary, + PaginationMeta, +} from '../api'; import { isAuthError } from '../auth/tokens'; import { TenantHeroCard, @@ -34,6 +42,9 @@ export default function BillingPage() { const [transactionCursor, setTransactionCursor] = React.useState(null); const [transactionsHasMore, setTransactionsHasMore] = React.useState(false); const [transactionsLoading, setTransactionsLoading] = React.useState(false); + const [addonHistory, setAddonHistory] = React.useState([]); + const [addonMeta, setAddonMeta] = React.useState(null); + const [addonsLoading, setAddonsLoading] = React.useState(false); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -55,6 +66,33 @@ export default function BillingPage() { [locale] ); + const resolveEventName = React.useCallback( + (event: TenantAddonHistoryEntry['event']) => { + const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed'); + if (!event) { + return fallback; + } + + if (typeof event.name === 'string' && event.name.trim().length > 0) { + return event.name; + } + + if (event.name && typeof event.name === 'object') { + const lang = i18n.language?.split('-')[0] ?? 'de'; + return ( + event.name[lang] ?? + event.name.de ?? + event.name.en ?? + Object.values(event.name)[0] ?? + fallback + ); + } + + return fallback; + }, + [i18n.language, t] + ); + const packageLabels = React.useMemo( () => ({ statusActive: t('billing.sections.packages.card.statusActive'), @@ -70,18 +108,24 @@ export default function BillingPage() { setLoading(true); setError(null); try { - const [packagesResult, paddleTransactions] = await Promise.all([ + const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([ getTenantPackagesOverview(force ? { force: true } : undefined), getTenantPaddleTransactions().catch((err) => { console.warn('Failed to load Paddle transactions', err); return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false }; }), + getTenantAddonHistory().catch((err) => { + console.warn('Failed to load add-on history', err); + return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } }; + }), ]); setPackages(packagesResult.packages); setActivePackage(packagesResult.activePackage); setTransactions(paddleTransactions.data); setTransactionCursor(paddleTransactions.nextCursor); setTransactionsHasMore(paddleTransactions.hasMore); + setAddonHistory(addonHistoryResult.data); + setAddonMeta(addonHistoryResult.meta); } catch (err) { if (!isAuthError(err)) { setError(t('billing.errors.load')); @@ -110,6 +154,24 @@ export default function BillingPage() { } }, [transactionCursor, transactionsHasMore, transactionsLoading]); + const loadMoreAddons = React.useCallback(async () => { + if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) { + return; + } + + setAddonsLoading(true); + try { + const nextPage = addonMeta.current_page + 1; + const result = await getTenantAddonHistory(nextPage); + setAddonHistory((current) => [...current, ...result.data]); + setAddonMeta(result.meta); + } catch (error) { + console.warn('Failed to load additional add-on history', error); + } finally { + setAddonsLoading(false); + } + }, [addonMeta, addonsLoading]); + React.useEffect(() => { void loadAll(); }, [loadAll]); @@ -118,6 +180,12 @@ export default function BillingPage() { () => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'), [activePackage, t, formatDate], ); + const hasMoreAddons = React.useMemo(() => { + if (!addonMeta) { + return false; + } + return addonMeta.current_page < addonMeta.last_page; + }, [addonMeta]); const heroBadge = t('billing.hero.badge', 'Abrechnung'); const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.'); @@ -288,6 +356,38 @@ export default function BillingPage() { + + + {addonHistory.length === 0 ? ( + + ) : ( + + )} + {hasMoreAddons && ( + + )} + + string; + formatDate: (value: string | null | undefined) => string; + resolveEventName: (event: TenantAddonHistoryEntry['event']) => string; + locale: string; + t: (key: string, options?: Record) => string; +}) { + const extrasLabel = (key: 'photos' | 'guests' | 'gallery', count: number) => + t(`billing.sections.addOns.extras.${key}`, { count }); + + return ( + + + + + + + + + + + + + {items.map((item) => { + const extras: string[] = []; + if (item.extra_photos > 0) { + extras.push(extrasLabel('photos', item.extra_photos)); + } + if (item.extra_guests > 0) { + extras.push(extrasLabel('guests', item.extra_guests)); + } + if (item.extra_gallery_days > 0) { + extras.push(extrasLabel('gallery', item.extra_gallery_days)); + } + + const purchasedLabel = item.purchased_at + ? new Date(item.purchased_at).toLocaleString(locale, { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + : formatDate(item.purchased_at); + + const statusKey = `billing.sections.addOns.status.${item.status}`; + const statusLabel = t(statusKey, { defaultValue: item.status }); + const statusTone: Record = { + completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200', + pending: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200', + failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200', + }; + + return ( + + + + + + + + ); + })} + +
{t('billing.sections.addOns.table.addon')}{t('billing.sections.addOns.table.event')}{t('billing.sections.addOns.table.amount')}{t('billing.sections.addOns.table.status')}{t('billing.sections.addOns.table.purchased')}
+
+ {item.label ?? item.addon_key} + {item.quantity > 1 ? ( + + ×{item.quantity} + + ) : null} +
+ {extras.length > 0 ? ( +

{extras.join(' · ')}

+ ) : null} +
+

{resolveEventName(item.event)}

+ {item.event?.slug ? ( +

{item.event.slug}

+ ) : null} +
+

+ {formatCurrency(item.amount, item.currency ?? 'EUR')} +

+ {item.receipt_url ? ( + + {t('billing.sections.transactions.labels.receipt')} + + ) : null} +
+ + {statusLabel} + + {purchasedLabel}
+
+ ); +} + function TransactionCard({ transaction, formatCurrency, diff --git a/resources/js/admin/pages/EventDetailPage.tsx b/resources/js/admin/pages/EventDetailPage.tsx index 68cb335..e6c040f 100644 --- a/resources/js/admin/pages/EventDetailPage.tsx +++ b/resources/js/admin/pages/EventDetailPage.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, @@ -16,6 +16,7 @@ import { RefreshCw, Smile, Sparkles, + ShoppingCart, Users, } from 'lucide-react'; import toast from 'react-hot-toast'; @@ -36,6 +37,7 @@ import { toggleEvent, submitTenantFeedback, updatePhotoVisibility, + createEventAddonCheckout, } from '../api'; import { buildLimitWarnings } from '../lib/limitWarnings'; import { getApiErrorMessage } from '../lib/apiError'; @@ -54,6 +56,9 @@ import { ActionGrid, TenantHeroCard, } from '../components/tenant'; +import { AddonsPicker } from '../components/Addons/AddonsPicker'; +import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; +import { EventAddonCatalogItem, getAddonCatalog } from '../api'; import { GuestBroadcastCard } from '../components/GuestBroadcastCard'; type EventDetailPageProps = { @@ -76,6 +81,7 @@ type WorkspaceState = { export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) { const { slug: slugParam } = useParams<{ slug?: string }>(); + const [searchParams] = useSearchParams(); const navigate = useNavigate(); const { t } = useTranslation('management'); const { t: tCommon } = useTranslation('common'); @@ -91,6 +97,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp }); const [toolkit, setToolkit] = React.useState({ data: null, loading: true, error: null }); + const [addonBusyId, setAddonBusyId] = React.useState(null); + const [addonRefreshCount, setAddonRefreshCount] = React.useState(0); + const [addonsCatalog, setAddonsCatalog] = React.useState([]); const load = React.useCallback(async () => { if (!slug) { @@ -103,8 +112,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp setToolkit((prev) => ({ ...prev, loading: true, error: null })); try { - const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]); + const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]); setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false })); + setAddonsCatalog(addonOptions); } catch (error) { if (!isAuthError(error)) { setState((prev) => ({ @@ -181,7 +191,43 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp [event?.limits, tCommon], ); - const shownWarningToasts = React.useRef>(new Set()); +const shownWarningToasts = React.useRef>(new Set()); + const [addonBusyId, setAddonBusyId] = React.useState(null); + + const handleAddonPurchase = React.useCallback( + async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => { + if (!slug) return; + + const defaultAddons: Record = { + photos: 'extra_photos_500', + guests: 'extra_guests_100', + gallery: 'extend_gallery_30d', + }; + + const addonKey = addonKeyOverride ?? defaultAddons[scope]; + setAddonBusyId(scope); + try { + const currentUrl = window.location.origin + window.location.pathname; + const successUrl = `${currentUrl}?addon_success=1`; + const checkout = await createEventAddonCheckout(slug, { + addon_key: addonKey, + quantity: 1, + success_url: successUrl, + cancel_url: currentUrl, + }); + if (checkout.checkout_url) { + window.location.href = checkout.checkout_url; + } else { + toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.')); + } + } catch (err) { + toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.'))); + } finally { + setAddonBusyId(null); + } + }, + [slug, t], + ); React.useEffect(() => { limitWarnings.forEach((warning) => { @@ -198,6 +244,30 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp }); }, [limitWarnings]); + React.useEffect(() => { + const success = searchParams.get('addon_success'); + if (success && slug) { + toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.')); + void load(); + setAddonRefreshCount(3); + const params = new URLSearchParams(window.location.search); + params.delete('addon_success'); + const search = params.toString(); + navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true }); + } + }, [searchParams, slug, load, navigate, t]); + + React.useEffect(() => { + if (addonRefreshCount <= 0) { + return; + } + const timer = setTimeout(() => { + void load(); + setAddonRefreshCount((count) => count - 1); + }, 8000); + return () => clearTimeout(timer); + }, [addonRefreshCount, load]); + if (!slug) { return ( - - - {warning.message} - +
+ + + {warning.message} + + {(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? ( +
+ + {addonsCatalog.length > 0 ? ( + { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }} + busy={addonBusyId === warning.scope} + t={(key, fallback) => t(key as any, fallback)} + /> + ) : null} +
+ ) : null} +
))} @@ -257,7 +356,17 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp navigate={navigate} /> - {(toolkitData?.alerts?.length ?? 0) > 0 && } + {(toolkitData?.alerts?.length ?? 0) > 0 && } + + {state.event?.addons?.length ? ( + + + t(key, fallback)} /> + + ) : null}
diff --git a/resources/js/admin/pages/EventInvitesPage.tsx b/resources/js/admin/pages/EventInvitesPage.tsx index 58b695c..f9fd9c5 100644 --- a/resources/js/admin/pages/EventInvitesPage.tsx +++ b/resources/js/admin/pages/EventInvitesPage.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Badge } from '@/components/ui/badge'; @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import toast from 'react-hot-toast'; import { AdminLayout } from '../components/AdminLayout'; import { @@ -20,6 +21,9 @@ import { TenantEvent, updateEventQrInvite, EventQrInviteLayout, + createEventAddonCheckout, + getAddonCatalog, + type EventAddonCatalogItem, } from '../api'; import { isAuthError } from '../auth/tokens'; import { @@ -29,6 +33,8 @@ import { ADMIN_EVENT_PHOTOS_PATH, } from '../constants'; import { buildLimitWarnings } from '../lib/limitWarnings'; +import { AddonsPicker } from '../components/Addons/AddonsPicker'; +import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; @@ -191,9 +197,14 @@ export default function EventInvitesPage(): React.ReactElement { setState((prev) => ({ ...prev, loading: true, error: null })); try { - const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]); + const [eventData, invitesData, catalog] = await Promise.all([ + getEvent(slug), + getEventQrInvites(slug), + getAddonCatalog(), + ]); setState({ event: eventData, invites: invitesData, loading: false, error: null }); setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null); + setAddonsCatalog(catalog); } catch (error) { if (!isAuthError(error)) { setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' }); @@ -765,6 +776,36 @@ export default function EventInvitesPage(): React.ReactElement { [state.event?.limits, tLimits] ); + const [addonBusy, setAddonBusy] = React.useState(null); + const [addonsCatalog, setAddonsCatalog] = React.useState([]); + const [searchParams] = useSearchParams(); + + const handleAddonPurchase = React.useCallback( + async (addonKey?: string) => { + if (!slug) return; + setAddonBusy('guests'); + const key = addonKey ?? 'extra_guests_100'; + try { + const currentUrl = window.location.origin + window.location.pathname; + const successUrl = `${currentUrl}?addon_success=1`; + const checkout = await createEventAddonCheckout(slug, { + addon_key: key, + quantity: 1, + success_url: successUrl, + cancel_url: currentUrl, + }); + if (checkout.checkout_url) { + window.location.href = checkout.checkout_url; + } + } catch (err) { + toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.')); + } finally { + setAddonBusy(null); + } + }, + [slug], + ); + const limitScopeLabels = React.useMemo( () => ({ photos: tLimits('photosTitle'), @@ -774,6 +815,16 @@ export default function EventInvitesPage(): React.ReactElement { [tLimits] ); + React.useEffect(() => { + const success = searchParams.get('addon_success'); + if (success && slug) { + toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.')); + void load(); + searchParams.delete('addon_success'); + navigate(window.location.pathname, { replace: true }); + } + }, [searchParams, slug, load, navigate, t]); + return ( - - - {limitScopeLabels[warning.scope]} - - - {warning.message} - +
+
+ + + {limitScopeLabels[warning.scope]} + + + {warning.message} + +
+ {warning.scope === 'guests' ? ( +
+ + { void handleAddonPurchase(key); }} + busy={addonBusy === 'guests'} + t={(key, fallback) => t(key as any, fallback)} + /> +
+ ) : null} +
))}
)} + {state.event?.addons?.length ? ( + + + {t('events.sections.addons.title', 'Add-ons & Upgrades')} + {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} + + + t(key as any, fallback)} /> + + + ) : null} + diff --git a/resources/js/admin/pages/EventPhotosPage.tsx b/resources/js/admin/pages/EventPhotosPage.tsx index 1e16f5a..be2c7eb 100644 --- a/resources/js/admin/pages/EventPhotosPage.tsx +++ b/resources/js/admin/pages/EventPhotosPage.tsx @@ -1,13 +1,17 @@ import React from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react'; +import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import toast from 'react-hot-toast'; +import { AddonsPicker } from '../components/Addons/AddonsPicker'; +import { AddonSummaryList } from '../components/Addons/AddonSummaryList'; +import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api'; import { AdminLayout } from '../components/AdminLayout'; -import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; +import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { isAuthError } from '../auth/tokens'; import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; @@ -31,6 +35,10 @@ export default function EventPhotosPage() { const [error, setError] = React.useState(undefined); const [busyId, setBusyId] = React.useState(null); const [limits, setLimits] = React.useState(null); + const [addons, setAddons] = React.useState([]); + const [catalogError, setCatalogError] = React.useState(undefined); + const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search)); + const [eventAddons, setEventAddons] = React.useState([]); const load = React.useCallback(async () => { if (!slug) { @@ -40,9 +48,16 @@ export default function EventPhotosPage() { setLoading(true); setError(undefined); try { - const result = await getEventPhotos(slug); - setPhotos(result.photos); - setLimits(result.limits ?? null); + const [photoResult, eventData, catalog] = await Promise.all([ + getEventPhotos(slug), + getEvent(slug), + getAddonCatalog(), + ]); + setPhotos(photoResult.photos); + setLimits(photoResult.limits ?? null); + setEventAddons(eventData.addons ?? []); + setAddons(catalog); + setCatalogError(undefined); } catch (err) { if (!isAuthError(err)) { setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); @@ -56,6 +71,18 @@ export default function EventPhotosPage() { load(); }, [load]); + React.useEffect(() => { + const success = searchParams.get('addon_success'); + if (success && slug) { + toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' })); + void load(); + const params = new URLSearchParams(searchParams); + params.delete('addon_success'); + setSearchParams(params); + navigate(window.location.pathname, { replace: true }); + } + }, [searchParams, slug, load, navigate, translateLimits]); + async function handleToggleFeature(photo: TenantPhoto) { if (!slug) return; setBusyId(photo.id); @@ -126,7 +153,19 @@ export default function EventPhotosPage() { )} - + + + {eventAddons.length > 0 && ( + + + {t('events.sections.addons.title', 'Add-ons & Upgrades')} + {t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')} + + + t(key as any, fallback)} /> + + + )} @@ -197,11 +236,49 @@ export default function EventPhotosPage() { function LimitWarningsBanner({ limits, translate, + eventSlug, + addons, }: { limits: EventLimitSummary | null; translate: (key: string, options?: Record) => string; + eventSlug: string | null; + addons: EventAddonCatalogItem[]; }) { const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); + const [busyScope, setBusyScope] = React.useState(null); + + const handleCheckout = React.useCallback( + async (scopeOrKey: 'photos' | 'gallery' | string) => { + if (!eventSlug) return; + const scope = scopeOrKey === 'gallery' || scopeOrKey === 'photos' ? scopeOrKey : (scopeOrKey.includes('gallery') ? 'gallery' : 'photos'); + setBusyScope(scope); + const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery' + ? (() => { + const fallbackKey = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d'; + const candidates = addons.filter((addon) => addon.price_id && addon.key.includes(scope === 'photos' ? 'photos' : 'gallery')); + return candidates[0]?.key ?? fallbackKey; + })() + : scopeOrKey; + try { + const currentUrl = window.location.origin + window.location.pathname; + const successUrl = `${currentUrl}?addon_success=1`; + const checkout = await createEventAddonCheckout(eventSlug, { + addon_key: addonKey, + quantity: 1, + success_url: successUrl, + cancel_url: currentUrl, + }); + if (checkout.checkout_url) { + window.location.href = checkout.checkout_url; + } + } catch (err) { + toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.')); + } finally { + setBusyScope(null); + } + }, + [eventSlug, addons], + ); if (!warnings.length) { return null; @@ -215,10 +292,36 @@ function LimitWarningsBanner({ variant={warning.tone === 'danger' ? 'destructive' : 'default'} className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined} > - - - {warning.message} - +
+ + + {warning.message} + + {warning.scope === 'photos' || warning.scope === 'gallery' ? ( +
+ +
+ { void handleCheckout(key); }} + busy={busyScope === warning.scope} + t={(key, fallback) => translate(key, { defaultValue: fallback })} + /> +
+
+ ) : null} +
))} diff --git a/resources/lang/de/emails.php b/resources/lang/de/emails.php index c6e82e0..e929d18 100644 --- a/resources/lang/de/emails.php +++ b/resources/lang/de/emails.php @@ -71,6 +71,8 @@ return [ 'subject' => 'Foto-Uploads für „:event“ sind aktuell blockiert', 'greeting' => 'Hallo :name,', 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Fotos erreicht. Gäste können keine neuen Fotos hochladen, bis Sie das Paket upgraden.', + 'cta_addon' => 'Brauchen Sie sofort mehr Uploads? Nutzen Sie das Add-on im Event-Dashboard, um zusätzliche Slots in Sekunden freizuschalten.', + 'addon_action' => 'Mehr Fotos freischalten', 'action' => 'Paket verwalten oder upgraden', ], 'guest_threshold' => [ @@ -83,6 +85,8 @@ return [ 'subject' => 'Gästekontingent für „:event“ ist ausgeschöpft', 'greeting' => 'Hallo :name,', 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Gästen erreicht. Neue Gästelinks können erst nach einem Upgrade erstellt werden.', + 'cta_addon' => 'Benötigen Sie sofort mehr Gästeplätze? Nutzen Sie das Add-on im Event-Dashboard, um direkt neue Slots freizuschalten.', + 'addon_action' => 'Mehr Gäste freischalten', 'action' => 'Paket verwalten oder upgraden', ], 'event_threshold' => [ @@ -129,4 +133,15 @@ return [ ], 'footer' => 'Viele Grüße
Ihr Fotospiel-Team', ], + + 'addons' => [ + 'receipt' => [ + 'subject' => 'Add-on gekauft: :addon', + 'greeting' => 'Hallo :name,', + 'body' => 'Sie haben „ :addon “ für das Event „ :event “ gebucht. Betrag: :amount.', + 'summary' => 'Enthalten: +:photos Fotos, +:guests Gäste, +:days Tage Galerie.', + 'unknown_amount' => 'k.A.', + 'action' => 'Event-Dashboard öffnen', + ], + ], ]; diff --git a/resources/lang/en/emails.php b/resources/lang/en/emails.php index fe9c356..f84f369 100644 --- a/resources/lang/en/emails.php +++ b/resources/lang/en/emails.php @@ -71,7 +71,9 @@ return [ 'subject' => 'Photo uploads for ":event" are currently blocked', 'greeting' => 'Hello :name,', 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit photos. Guests can no longer upload new photos until you upgrade the package.', + 'cta_addon' => 'Need more uploads right now? Use the in-app add-on to unlock additional photo slots instantly.', 'action' => 'Upgrade or manage package', + 'addon_action' => 'Unlock more photos', ], 'guest_threshold' => [ 'subject' => 'Event ":event" has used :percentage% of its guest allowance', @@ -83,7 +85,9 @@ return [ 'subject' => 'Guest slots for ":event" are currently exhausted', 'greeting' => 'Hello :name,', 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit guests. New guest invites cannot be created until you upgrade the package.', + 'cta_addon' => 'Need more guest access right away? Use the add-on button inside the event dashboard to unlock extra slots within seconds.', 'action' => 'Upgrade or manage package', + 'addon_action' => 'Unlock more guests', ], 'event_threshold' => [ 'subject' => 'Package ":package" has used :percentage% of its event allowance', @@ -129,4 +133,15 @@ return [ ], 'footer' => 'Best regards,
The Fotospiel Team', ], + + 'addons' => [ + 'receipt' => [ + 'subject' => 'Add-on purchase: :addon', + 'greeting' => 'Hello :name,', + 'body' => 'You purchased " :addon " for the event " :event ". Amount: :amount.', + 'summary' => 'Included: +:photos photos, +:guests guests, +:days gallery days.', + 'unknown_amount' => 'n/a', + 'action' => 'Open event dashboard', + ], + ], ]; diff --git a/resources/views/emails/addons/receipt.blade.php b/resources/views/emails/addons/receipt.blade.php new file mode 100644 index 0000000..783f217 --- /dev/null +++ b/resources/views/emails/addons/receipt.blade.php @@ -0,0 +1,44 @@ +@php + /** @var \App\Models\EventPackageAddon $addon */ + $event = $addon->event; + $tenant = $event?->tenant; + $label = $addon->metadata['label'] ?? $addon->addon_key; + $amount = $addon->amount ? number_format((float) $addon->amount, 2).' '.($addon->currency ?? 'EUR') : __('emails.addons.receipt.unknown_amount'); + $summary = []; + if ($addon->extra_photos > 0) { + $summary[] = __('emails.addons.receipt.summary.photos', ['count' => number_format($addon->extra_photos)]); + } + if ($addon->extra_guests > 0) { + $summary[] = __('emails.addons.receipt.summary.guests', ['count' => number_format($addon->extra_guests)]); + } + if ($addon->extra_gallery_days > 0) { + $summary[] = __('emails.addons.receipt.summary.gallery', ['count' => $addon->extra_gallery_days]); + } +@endphp + +@component('mail::message') +# {{ __('emails.addons.receipt.subject', ['addon' => $label]) }} + +{{ __('emails.addons.receipt.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]) }} + +{{ __('emails.addons.receipt.body', [ + 'addon' => $label, + 'event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'), + 'amount' => $amount, +]) }} + +@if(!empty($summary)) +**{{ __('emails.addons.receipt.summary_title', 'Included:') }}** + +@foreach($summary as $line) +- {{ $line }} +@endforeach +@endif + +@component('mail::button', ['url' => url('/tenant/events/'.($event?->slug ?? ''))]) +{{ __('emails.addons.receipt.action') }} +@endcomponent + +{{ __('emails.package_limits.footer') }} + +@endcomponent diff --git a/routes/api.php b/routes/api.php index ac8372b..60dcc88 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,8 @@ use App\Http\Controllers\Api\Marketing\CouponPreviewController; use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\Tenant\DashboardController; use App\Http\Controllers\Api\Tenant\EmotionController; +use App\Http\Controllers\Api\Tenant\EventAddonCatalogController; +use App\Http\Controllers\Api\Tenant\EventAddonController; use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventGuestNotificationController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController; @@ -148,6 +150,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit'); Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index'); Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store'); + Route::post('addons/apply', [EventAddonController::class, 'apply'])->name('tenant.events.addons.apply'); + Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout'); }); Route::prefix('join-tokens')->group(function () { @@ -266,13 +270,25 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); }); + Route::get('addons/catalog', [EventAddonCatalogController::class, 'index']) + ->middleware('tenant.admin') + ->name('tenant.addons.catalog'); + Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () { Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index'); }); - Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) - ->middleware('tenant.admin') - ->name('tenant.billing.transactions'); + Route::prefix('billing')->middleware('tenant.admin')->group(function () { + Route::get('transactions', [TenantBillingController::class, 'transactions']) + ->name('tenant.billing.transactions'); + Route::get('addons', [TenantBillingController::class, 'addons']) + ->name('tenant.billing.addons'); + }); + + Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () { + Route::get('transactions', [TenantBillingController::class, 'transactions']); + Route::get('addons', [TenantBillingController::class, 'addons']); + }); Route::post('feedback', [TenantFeedbackController::class, 'store']) ->name('tenant.feedback.store'); diff --git a/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php new file mode 100644 index 0000000..3767d63 --- /dev/null +++ b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php @@ -0,0 +1,102 @@ +endcustomer()->create([ + 'max_photos' => 500, + 'max_guests' => 200, + 'gallery_days' => 60, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'name' => ['de' => 'Gala', 'en' => 'Gala'], + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subMonth(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(30), + ]); + + $firstAddon = EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'extra_guests_50', + 'quantity' => 1, + 'extra_guests' => 50, + 'status' => 'completed', + 'amount' => 99.00, + 'currency' => 'EUR', + 'metadata' => ['label' => '+50 Gäste'], + 'purchased_at' => now()->subDay(), + 'receipt_payload' => ['receipt_url' => 'https://receipt.example/first'], + ]); + + $secondAddon = EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'extra_photos_200', + 'quantity' => 2, + 'extra_photos' => 200, + 'extra_guests' => 0, + 'status' => 'pending', + 'amount' => 149.00, + 'currency' => 'EUR', + 'metadata' => ['label' => '+200 Fotos'], + 'purchased_at' => now(), + 'receipt_payload' => ['receipt_url' => 'https://receipt.example/second'], + ]); + + $otherTenant = Tenant::factory()->create(); + $otherEvent = Event::factory()->for($otherTenant)->create(); + $otherPackage = EventPackage::create([ + 'event_id' => $otherEvent->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subWeek(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(30), + ]); + + EventPackageAddon::create([ + 'event_package_id' => $otherPackage->id, + 'event_id' => $otherEvent->id, + 'tenant_id' => $otherTenant->id, + 'addon_key' => 'extra_guests_999', + 'quantity' => 1, + 'extra_guests' => 999, + 'status' => 'completed', + 'amount' => 100.00, + 'currency' => 'EUR', + 'purchased_at' => now(), + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons'); + + $response->assertOk(); + $response->assertJsonCount(2, 'data'); + $response->assertJsonPath('meta.total', 2); + $response->assertJsonPath('data.0.id', $secondAddon->id); + $response->assertJsonPath('data.0.receipt_url', 'https://receipt.example/second'); + $response->assertJsonPath('data.0.event.slug', $event->slug); + $response->assertJsonPath('data.1.id', $firstAddon->id); + } +} diff --git a/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php new file mode 100644 index 0000000..5242182 --- /dev/null +++ b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php @@ -0,0 +1,52 @@ +endcustomer()->create([ + 'max_guests' => 50, + 'max_photos' => 100, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(30), + 'extra_guests' => 20, + ]); + + EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'extra_guests_100', + 'quantity' => 1, + 'extra_guests' => 100, + 'status' => 'completed', + 'purchased_at' => now(), + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}"); + + $response->assertOk(); + $response->assertJsonPath('data.addons.0.key', 'extra_guests_100'); + $response->assertJsonPath('data.addons.0.extra_guests', 100); + } +} diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php new file mode 100644 index 0000000..51d6860 --- /dev/null +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -0,0 +1,81 @@ + 'Extra photos (500)', + 'price_id' => 'pri_addon_photos', + 'increments' => ['extra_photos' => 500], + ]); + + Config::set('paddle.api_key', 'test_key'); + Config::set('paddle.base_url', 'https://paddle.test'); + Config::set('paddle.environment', 'sandbox'); + + // Fake Paddle response + Http::fake([ + '*/checkout/links' => Http::response([ + 'data' => [ + 'url' => 'https://checkout.paddle.test/abcd', + 'id' => 'chk_addon_123', + 'expires_at' => now()->addHour()->toIso8601String(), + ], + ], 200), + ]); + } + + public function test_checkout_creates_pending_addon_record(): void + { + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + 'max_guests' => 50, + 'gallery_days' => 7, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ + 'addon_key' => 'extra_photos_small', + 'quantity' => 2, + ]); + + $response->assertOk(); + $response->assertJsonPath('checkout_id', 'chk_addon_123'); + + $this->assertDatabaseHas('event_package_addons', [ + 'event_package_id' => $eventPackage->id, + 'addon_key' => 'extra_photos_small', + 'status' => 'pending', + 'quantity' => 2, + 'checkout_id' => 'chk_addon_123', + ]); + + $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); + $this->assertSame(1000, $addon->extra_photos); // increments * quantity + } +} diff --git a/tests/Feature/Tenant/EventAddonControllerTest.php b/tests/Feature/Tenant/EventAddonControllerTest.php new file mode 100644 index 0000000..a1332a8 --- /dev/null +++ b/tests/Feature/Tenant/EventAddonControllerTest.php @@ -0,0 +1,78 @@ +endcustomer()->create([ + 'max_photos' => 100, + 'max_guests' => 50, + 'gallery_days' => 7, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subDay(), + 'used_photos' => 10, + 'used_guests' => 5, + 'gallery_expires_at' => Carbon::now()->addDays(7), + ]); + + $originalExpiry = $eventPackage->gallery_expires_at->copy(); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", [ + 'extra_photos' => 50, + 'extra_guests' => 25, + 'extend_gallery_days' => 3, + 'reason' => 'Manual boost for event', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.limits.photos.limit', 150); + $response->assertJsonPath('data.limits.guests.limit', 75); + + $eventPackage->refresh(); + + $this->assertSame(50, $eventPackage->extra_photos); + $this->assertSame(25, $eventPackage->extra_guests); + $this->assertSame(3, $eventPackage->extra_gallery_days); + $this->assertTrue($eventPackage->gallery_expires_at->isSameDay($originalExpiry->addDays(3))); + } + + public function test_validation_fails_when_no_addons_provided(): void + { + $package = Package::factory()->endcustomer()->create(); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors('addons'); + } +} diff --git a/tests/Feature/Tenant/EventAddonWebhookTest.php b/tests/Feature/Tenant/EventAddonWebhookTest.php new file mode 100644 index 0000000..3b2493a --- /dev/null +++ b/tests/Feature/Tenant/EventAddonWebhookTest.php @@ -0,0 +1,83 @@ + 'Guests 100', + 'increments' => ['extra_guests' => 100], + 'price_id' => 'pri_guests', + ]); + + $package = Package::factory()->endcustomer()->create([ + 'max_guests' => 50, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $addon = EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'extra_guests', + 'quantity' => 1, + 'extra_guests' => 100, + 'status' => 'pending', + 'metadata' => [ + 'addon_intent' => 'intent-123', + ], + ]); + + $payload = [ + 'event_type' => 'transaction.completed', + 'data' => [ + 'id' => 'txn_addon_1', + 'metadata' => [ + 'addon_intent' => 'intent-123', + 'addon_key' => 'extra_guests', + ], + ], + ]; + + $handler = app(EventAddonWebhookService::class); + + $handled = $handler->handle($payload); + + $this->assertTrue($handled); + + $addon->refresh(); + $eventPackage->refresh(); + + $this->assertSame('completed', $addon->status); + $this->assertSame('txn_addon_1', $addon->transaction_id); + $this->assertSame(100, $eventPackage->extra_guests); + + Notification::assertSentTimes(AddonPurchaseReceipt::class, 1); + } +} diff --git a/tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php b/tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php new file mode 100644 index 0000000..8bc30fd --- /dev/null +++ b/tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php @@ -0,0 +1,42 @@ + 'extra_photos_500', + 'label' => '+500 Fotos', + 'extra_photos' => 500, + 'metadata' => ['price_eur' => 5], + ]); + + $service = Mockery::mock(PaddleAddonCatalogService::class); + $service->shouldReceive('createProduct') + ->once() + ->andReturn(['id' => 'pro_addon_1']); + $service->shouldReceive('createPrice') + ->once() + ->andReturn(['id' => 'pri_addon_1']); + + $job = new SyncPackageAddonToPaddle($addon->id); + $job->handle($service); + + $addon->refresh(); + + $this->assertSame('pri_addon_1', $addon->price_id); + $this->assertEquals('pro_addon_1', $addon->metadata['paddle_product_id']); + $this->assertEquals('synced', $addon->metadata['paddle_sync_status']); + } +} diff --git a/tests/Unit/Services/EventAddonCatalogTest.php b/tests/Unit/Services/EventAddonCatalogTest.php new file mode 100644 index 0000000..c068436 --- /dev/null +++ b/tests/Unit/Services/EventAddonCatalogTest.php @@ -0,0 +1,43 @@ + [ + 'label' => 'Config Photos', + 'price_id' => 'pri_config', + 'increments' => ['extra_photos' => 100], + ], + ]); + + PackageAddon::create([ + 'key' => 'extra_photos_small', + 'label' => 'DB Photos', + 'price_id' => 'pri_db', + 'extra_photos' => 200, + 'active' => true, + 'sort' => 1, + ]); + + $catalog = $this->app->make(EventAddonCatalog::class); + + $addon = $catalog->find('extra_photos_small'); + + $this->assertNotNull($addon); + $this->assertSame('DB Photos', $addon['label']); + $this->assertSame('pri_db', $addon['price_id']); + $this->assertSame(200, $addon['increments']['extra_photos']); + } +} diff --git a/tests/Unit/Services/PackageLimitEvaluatorTest.php b/tests/Unit/Services/PackageLimitEvaluatorTest.php index dc8d616..fdcc8bc 100644 --- a/tests/Unit/Services/PackageLimitEvaluatorTest.php +++ b/tests/Unit/Services/PackageLimitEvaluatorTest.php @@ -165,4 +165,40 @@ class PackageLimitEvaluatorTest extends TestCase $this->assertTrue($summary['can_upload_photos']); $this->assertTrue($summary['can_add_guests']); } + + public function test_assess_photo_upload_respects_extra_limits(): void + { + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 5, + ]); + + $tenant = Tenant::factory()->create(); + + $event = Event::factory() + ->for($tenant) + ->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 5, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(14), + 'extra_photos' => 5, + ])->fresh(['package']); + + $violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id); + + $this->assertNull($violation, 'Upload should be allowed within extra photo allowance'); + + $eventPackage->update(['used_photos' => 10]); + + $violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id); + + $this->assertNotNull($violation, 'Upload should be blocked after exceeding base + extras'); + $this->assertSame('photo_limit_exceeded', $violation['code']); + $this->assertSame(0, $violation['meta']['remaining']); + } } diff --git a/tests/Unit/Services/PackageUsageTrackerTest.php b/tests/Unit/Services/PackageUsageTrackerTest.php index a735490..74164de 100644 --- a/tests/Unit/Services/PackageUsageTrackerTest.php +++ b/tests/Unit/Services/PackageUsageTrackerTest.php @@ -145,4 +145,41 @@ class PackageUsageTrackerTest extends TestCase EventFacade::assertDispatched(EventPackageGuestLimitReached::class); } + + public function test_effective_limits_include_extras(): void + { + EventFacade::fake([ + EventPackagePhotoLimitReached::class, + ]); + + $tenant = Tenant::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 2, + ]); + $event = Event::factory()->for($tenant)->create(); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 2, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + 'extra_photos' => 2, + ])->fresh(['package']); + + /** @var PackageUsageTracker $tracker */ + $tracker = app(PackageUsageTracker::class); + + // Base limit reached but extras still available; no limit event expected yet. + $tracker->recordPhotoUsage($eventPackage, 1, 1); + EventFacade::assertNotDispatched(EventPackagePhotoLimitReached::class); + + // Now consume extras and hit the effective limit. + $eventPackage->used_photos = 4; + $tracker->recordPhotoUsage($eventPackage, 3, 1); + + EventFacade::assertDispatched(EventPackagePhotoLimitReached::class); + } }