diff --git a/app/Filament/Resources/PackageAddonResource.php b/app/Filament/Resources/PackageAddonResource.php index 86372089..e5683989 100644 --- a/app/Filament/Resources/PackageAddonResource.php +++ b/app/Filament/Resources/PackageAddonResource.php @@ -5,14 +5,20 @@ namespace App\Filament\Resources; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Resources\PackageAddonResource\Pages; use App\Jobs\SyncPackageAddonToLemonSqueezy; +use App\Models\CheckoutSession; use App\Models\PackageAddon; use App\Services\Audit\SuperAdminAuditLogger; use Filament\Actions; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TagsInput; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Utilities\Get; use Filament\Schemas\Schema; use Filament\Tables; use Filament\Tables\Columns\BadgeColumn; @@ -53,7 +59,15 @@ class PackageAddonResource extends Resource TextInput::make('variant_id') ->label('Lemon Squeezy Variant-ID') ->helperText('Variant-ID aus Lemon Squeezy für dieses Add-on') + ->required(fn (Get $get): bool => (bool) $get('active') && ! is_numeric($get('metadata.price_eur'))) ->maxLength(191), + TextInput::make('metadata.price_eur') + ->label('PayPal Preis (EUR)') + ->helperText('Für PayPal-Checkout erforderlich (z. B. 9.90).') + ->numeric() + ->step(0.01) + ->minValue(0.01) + ->required(fn (Get $get): bool => (bool) $get('active') && blank($get('variant_id'))), TextInput::make('sort') ->label('Sortierung') ->numeric() @@ -61,6 +75,23 @@ class PackageAddonResource extends Resource Toggle::make('active') ->label('Aktiv') ->default(true), + Placeholder::make('sellable_state') + ->label('Verfügbarkeits-Check') + ->content(function (Get $get): string { + $isActive = (bool) $get('active'); + $hasVariant = filled($get('variant_id')); + $hasPayPalPrice = is_numeric($get('metadata.price_eur')); + + if (! $isActive) { + return 'Inaktiv'; + } + + if (! $hasVariant && ! $hasPayPalPrice) { + return 'Nicht verkäuflich: Variant-ID oder PayPal Preis fehlt.'; + } + + return 'Verkäuflich'; + }), ]), Section::make('Limits-Inkremente') ->columns(3) @@ -81,6 +112,30 @@ class PackageAddonResource extends Resource ->minValue(0) ->default(0), ]), + Section::make('Feature-Entitlements') + ->columns(2) + ->schema([ + Select::make('metadata.scope') + ->label('Scope') + ->options([ + 'photos' => 'Fotos', + 'guests' => 'Gäste', + 'gallery' => 'Galerie', + 'feature' => 'Feature', + 'bundle' => 'Bundle', + ]) + ->native(false) + ->searchable(), + TagsInput::make('metadata.entitlements.features') + ->label('Freigeschaltete Features') + ->helperText('Feature-Keys für Freischaltungen, z. B. ai_styling') + ->placeholder('z. B. ai_styling') + ->columnSpanFull(), + DateTimePicker::make('metadata.entitlements.expires_at') + ->label('Entitlement gültig bis') + ->seconds(false) + ->nullable(), + ]), ]); } @@ -100,6 +155,29 @@ class PackageAddonResource extends Resource ->label('Lemon Squeezy Variant-ID') ->toggleable() ->copyable(), + TextColumn::make('metadata.price_eur') + ->label('PayPal Preis (EUR)') + ->formatStateUsing(fn (mixed $state): string => is_numeric($state) ? number_format((float) $state, 2, ',', '.').' €' : '—') + ->toggleable(), + TextColumn::make('metadata.scope') + ->label('Scope') + ->badge() + ->toggleable(), + TextColumn::make('metadata.entitlements.features') + ->label('Features') + ->formatStateUsing(function (mixed $state): string { + if (! is_array($state)) { + return '—'; + } + + $features = array_values(array_filter(array_map( + static fn (mixed $feature): string => trim((string) $feature), + $state, + ))); + + return $features === [] ? '—' : implode(', ', $features); + }) + ->toggleable(), TextColumn::make('extra_photos')->label('Fotos +'), TextColumn::make('extra_guests')->label('Gäste +'), TextColumn::make('extra_gallery_days')->label('Galerietage +'), @@ -110,6 +188,14 @@ class PackageAddonResource extends Resource 'danger' => false, ]) ->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'), + BadgeColumn::make('sellability') + ->label('Checkout') + ->state(fn (PackageAddon $record): string => static::sellabilityLabel($record)) + ->colors([ + 'success' => fn (string $state): bool => $state === 'Verkäuflich', + 'warning' => fn (string $state): bool => $state === 'Unvollständig', + 'gray' => fn (string $state): bool => $state === 'Inaktiv', + ]), TextColumn::make('sort') ->label('Sort') ->sortable() @@ -166,4 +252,21 @@ class PackageAddonResource extends Resource 'edit' => Pages\EditPackageAddon::route('/{record}/edit'), ]; } + + protected static function sellabilityLabel(PackageAddon $record): string + { + if (! $record->active) { + return 'Inaktiv'; + } + + return $record->isSellableForProvider(static::addonProvider()) ? 'Verkäuflich' : 'Unvollständig'; + } + + protected static function addonProvider(): string + { + return (string) ( + config('package-addons.provider') + ?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL) + ); + } } diff --git a/app/Filament/Resources/PackageResource.php b/app/Filament/Resources/PackageResource.php index e1a2e1f0..8b5b2849 100644 --- a/app/Filament/Resources/PackageResource.php +++ b/app/Filament/Resources/PackageResource.php @@ -4,12 +4,9 @@ namespace App\Filament\Resources; use App\Filament\Clusters\WeeklyOps\WeeklyOpsCluster; use App\Filament\Resources\PackageResource\Pages; -use App\Jobs\PullPackageFromLemonSqueezy; -use App\Jobs\SyncPackageToLemonSqueezy; use App\Models\Package; use App\Services\Audit\SuperAdminAuditLogger; use BackedEnum; -use Filament\Actions; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteAction; use Filament\Actions\DeleteBulkAction; @@ -26,7 +23,6 @@ use Filament\Forms\Components\Repeater; use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; -use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Schemas\Components\Section; use Filament\Schemas\Components\Tabs as SchemaTabs; @@ -301,71 +297,6 @@ class PackageResource extends Resource TrashedFilter::make(), ]) ->actions([ - Actions\Action::make('syncLemonSqueezy') - ->label('Mit Lemon Squeezy abgleichen') - ->icon('heroicon-o-cloud-arrow-up') - ->color('success') - ->requiresConfirmation() - ->disabled(fn (Package $record) => $record->lemonsqueezy_sync_status === 'syncing') - ->action(function (Package $record) { - SyncPackageToLemonSqueezy::dispatch($record->id); - - Notification::make() - ->success() - ->title('Lemon Squeezy-Sync gestartet') - ->body('Das Paket wird im Hintergrund mit Lemon Squeezy abgeglichen.') - ->send(); - }), - Actions\Action::make('linkLemonSqueezy') - ->label('Lemon Squeezy verknüpfen') - ->icon('heroicon-o-link') - ->color('info') - ->form([ - TextInput::make('lemonsqueezy_product_id') - ->label('Lemon Squeezy Produkt-ID') - ->required() - ->maxLength(191), - TextInput::make('lemonsqueezy_variant_id') - ->label('Lemon Squeezy Variant-ID') - ->required() - ->maxLength(191), - ]) - ->fillForm(fn (Package $record) => [ - 'lemonsqueezy_product_id' => $record->lemonsqueezy_product_id, - 'lemonsqueezy_variant_id' => $record->lemonsqueezy_variant_id, - ]) - ->action(function (Package $record, array $data): void { - $record->linkLemonSqueezyIds($data['lemonsqueezy_product_id'], $data['lemonsqueezy_variant_id']); - - PullPackageFromLemonSqueezy::dispatch($record->id); - - app(SuperAdminAuditLogger::class)->recordModelMutation( - 'linked', - $record, - SuperAdminAuditLogger::fieldsMetadata($data), - static::class - ); - - Notification::make() - ->success() - ->title('Lemon Squeezy-Verknüpfung gespeichert') - ->body('Die IDs wurden gespeichert und ein Pull wurde angestoßen.') - ->send(); - }), - Actions\Action::make('pullLemonSqueezy') - ->label('Status von Lemon Squeezy holen') - ->icon('heroicon-o-cloud-arrow-down') - ->disabled(fn (Package $record) => ! $record->lemonsqueezy_product_id && ! $record->lemonsqueezy_variant_id) - ->requiresConfirmation() - ->action(function (Package $record) { - PullPackageFromLemonSqueezy::dispatch($record->id); - - Notification::make() - ->info() - ->title('Lemon Squeezy-Abgleich angefordert') - ->body('Der aktuelle Stand aus Lemon Squeezy wird geladen und hier hinterlegt.') - ->send(); - }), ViewAction::make(), EditAction::make() ->after(fn (array $data, Package $record) => app(SuperAdminAuditLogger::class)->recordModelMutation( diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index 6997b51b..225b8336 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -27,21 +27,22 @@ class PackageController extends Controller public function index(Request $request): JsonResponse { $type = $request->query('type', 'endcustomer'); + $provider = strtolower((string) config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL)); $packages = Package::where('type', $type) ->orderBy('price') ->get(); - $packages->each(function ($package) { + $packages->each(function ($package) use ($provider) { if (is_string($package->features)) { $decoded = json_decode($package->features, true); $package->features = is_array($decoded) ? $decoded : []; - return; - } - - if (! is_array($package->features)) { + } elseif (! is_array($package->features)) { $package->features = []; } + + $package->setAttribute('checkout_provider', $provider); + $package->setAttribute('can_checkout', $this->canCheckoutPackage($package, $provider)); }); return response()->json([ @@ -365,4 +366,17 @@ class PackageController extends Controller 'cancel_url' => $cancelUrl, ]); } + + private function canCheckoutPackage(Package $package, string $provider): bool + { + if ((float) $package->price <= 0) { + return true; + } + + if ($provider === CheckoutSession::PROVIDER_LEMONSQUEEZY) { + return filled($package->lemonsqueezy_variant_id); + } + + return true; + } } diff --git a/app/Http/Controllers/Api/Tenant/EventAddonController.php b/app/Http/Controllers/Api/Tenant/EventAddonController.php index 5f76b180..759aad51 100644 --- a/app/Http/Controllers/Api/Tenant/EventAddonController.php +++ b/app/Http/Controllers/Api/Tenant/EventAddonController.php @@ -4,13 +4,10 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Tenant\EventAddonCheckoutRequest; -use App\Http\Requests\Tenant\EventAddonRequest; -use App\Http\Resources\Tenant\EventResource; use App\Models\Event; use App\Services\Addons\EventAddonCheckoutService; use App\Support\ApiError; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Carbon; class EventAddonController extends Controller { @@ -51,64 +48,4 @@ class EventAddonController extends Controller '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/TenantBillingController.php b/app/Http/Controllers/Api/TenantBillingController.php index 83aa55a5..6e06a3f2 100644 --- a/app/Http/Controllers/Api/TenantBillingController.php +++ b/app/Http/Controllers/Api/TenantBillingController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Tenant\BillingAddonHistoryRequest; use App\Models\Event; use App\Models\EventPackageAddon; use App\Models\PackagePurchase; +use App\Services\Addons\EventAddonCatalog; use App\Services\LemonSqueezy\Exceptions\LemonSqueezyException; use App\Services\LemonSqueezy\LemonSqueezySubscriptionService; use Dompdf\Dompdf; @@ -22,6 +23,7 @@ class TenantBillingController extends Controller { public function __construct( private readonly LemonSqueezySubscriptionService $subscriptions, + private readonly EventAddonCatalog $addonCatalog, ) {} public function transactions(Request $request): JsonResponse @@ -132,11 +134,19 @@ class TenantBillingController extends Controller ->orderByDesc('created_at') ->paginate($perPage, ['*'], 'page', $page); - $data = $paginator->getCollection()->map(function (EventPackageAddon $addon) { + $addonLabels = collect($this->addonCatalog->all()) + ->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null]) + ->all(); + + $data = $paginator->getCollection()->map(function (EventPackageAddon $addon) use ($addonLabels) { + $label = $addon->metadata['label'] + ?? ($addonLabels[$addon->addon_key] ?? null) + ?? $addon->addon_key; + return [ 'id' => $addon->id, 'addon_key' => $addon->addon_key, - 'label' => $addon->metadata['label'] ?? null, + 'label' => $label, 'quantity' => (int) ($addon->quantity ?? 1), 'status' => $addon->status, 'amount' => $addon->amount !== null ? (float) $addon->amount : null, diff --git a/app/Http/Requests/Tenant/EventAddonRequest.php b/app/Http/Requests/Tenant/EventAddonRequest.php deleted file mode 100644 index 442ec7c6..00000000 --- a/app/Http/Requests/Tenant/EventAddonRequest.php +++ /dev/null @@ -1,40 +0,0 @@ - - */ - 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 1ecf4d93..a8ffcf0b 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Tenant; use App\Models\WatermarkSetting; +use App\Services\Addons\EventAddonCatalog; use App\Services\AiEditing\AiStylingEntitlementService; use App\Services\AiEditing\EventAiEditingPolicyService; use App\Services\Packages\PackageLimitEvaluator; @@ -222,11 +223,17 @@ class EventResource extends JsonResource ? $eventPackage->addons : $eventPackage->addons()->latest()->take(10)->get(); - return $addons->map(function ($addon) { + $addonLabels = collect(app(EventAddonCatalog::class)->all()) + ->mapWithKeys(fn (array $addon, string $key): array => [$key => $addon['label'] ?? null]) + ->all(); + + return $addons->map(function ($addon) use ($addonLabels) { return [ 'id' => $addon->id, 'key' => $addon->addon_key, - 'label' => $addon->metadata['label'] ?? null, + 'label' => $addon->metadata['label'] + ?? ($addonLabels[$addon->addon_key] ?? null) + ?? $addon->addon_key, 'status' => $addon->status, 'variant_id' => $addon->variant_id, 'transaction_id' => $addon->transaction_id, diff --git a/app/Models/PackageAddon.php b/app/Models/PackageAddon.php index 4a4a5f80..ebd87942 100644 --- a/app/Models/PackageAddon.php +++ b/app/Models/PackageAddon.php @@ -41,4 +41,27 @@ class PackageAddon extends Model ], ); } + + public function resolvePaypalPrice(): ?float + { + $metadata = is_array($this->metadata) ? $this->metadata : []; + $price = $metadata['price_eur'] ?? null; + + return is_numeric($price) ? (float) $price : null; + } + + public function isSellableForProvider(?string $provider = null): bool + { + $provider = strtolower((string) ($provider ?? config('package-addons.provider') ?? config('checkout.default_provider', 'paypal'))); + + if (! $this->active) { + return false; + } + + if ($provider === 'lemonsqueezy') { + return filled($this->variant_id); + } + + return $this->resolvePaypalPrice() !== null; + } } diff --git a/app/Services/Addons/EventAddonCatalog.php b/app/Services/Addons/EventAddonCatalog.php index 476af0f9..22d10230 100644 --- a/app/Services/Addons/EventAddonCatalog.php +++ b/app/Services/Addons/EventAddonCatalog.php @@ -17,18 +17,43 @@ class EventAddonCatalog ->orderBy('sort') ->get() ->mapWithKeys(function (PackageAddon $addon) { + $metadata = $this->normalizeMetadata($addon->metadata); + return [$addon->key => [ 'label' => $addon->label, 'variant_id' => $addon->variant_id, 'increments' => $addon->increments, - 'price' => $this->resolveMetadataPrice($addon->metadata ?? []), + 'price' => $addon->resolvePaypalPrice(), 'currency' => 'EUR', + 'metadata' => $metadata, ]]; }) ->all(); // Fallback to config and merge (DB wins) - $configAddons = config('package-addons', []); + $configAddons = collect((array) config('package-addons', [])) + ->filter(static fn (mixed $addon): bool => is_array($addon)) + ->map(function (array $addon): array { + $metadata = $this->normalizeMetadata($addon['metadata'] ?? []); + + if (! isset($metadata['scope']) && is_string($addon['scope'] ?? null) && trim((string) $addon['scope']) !== '') { + $metadata['scope'] = trim((string) $addon['scope']); + } + + if (! isset($metadata['entitlements']) && is_array($addon['entitlements'] ?? null)) { + $metadata['entitlements'] = $addon['entitlements']; + } + + return [ + 'label' => $addon['label'] ?? null, + 'variant_id' => $addon['variant_id'] ?? null, + 'increments' => Arr::get($addon, 'increments', []), + 'price' => $this->resolveConfiguredPrice($addon), + 'currency' => $addon['currency'] ?? 'EUR', + 'metadata' => $metadata, + ]; + }) + ->all(); return array_merge($configAddons, $dbAddons); } @@ -61,13 +86,29 @@ class EventAddonCatalog return is_numeric($price) ? (float) $price : null; } - protected function resolveMetadataPrice(array $metadata): ?float + protected function resolveConfiguredPrice(array $addon): ?float { - $price = $metadata['price_eur'] ?? null; + $price = $addon['price'] ?? $addon['price_eur'] ?? null; return is_numeric($price) ? (float) $price : null; } + /** + * @return array + */ + protected function normalizeMetadata(mixed $metadata): array + { + if (is_string($metadata)) { + $decoded = json_decode($metadata, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $metadata = $decoded; + } + } + + return is_array($metadata) ? $metadata : []; + } + /** * @return array */ diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index 1b1c601b..49262601 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -9,6 +9,7 @@ use App\Models\Tenant; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; use App\Services\PayPal\Exceptions\PayPalException; use App\Services\PayPal\PayPalOrderService; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; @@ -31,8 +32,9 @@ class EventAddonCheckoutService $quantity = max(1, (int) ($payload['quantity'] ?? 1)); $acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false); $acceptedTerms = (bool) ($payload['accepted_terms'] ?? false); + $addon = is_string($addonKey) ? $this->catalog->find($addonKey) : null; - if (! $addonKey || ! $this->catalog->find($addonKey)) { + if (! is_string($addonKey) || $addon === null) { throw ValidationException::withMessages([ 'addon_key' => __('Unbekanntes Add-on.'), ]); @@ -42,27 +44,29 @@ class EventAddonCheckoutService if (! $event->eventPackage) { throw ValidationException::withMessages([ - 'event' => __('Kein Paket für dieses Event hinterlegt.'), + 'event' => __('Kein Paket fuer dieses Event hinterlegt.'), ]); } $provider = $this->resolveProvider(); if ($provider === CheckoutSession::PROVIDER_PAYPAL) { - return $this->createPayPalCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload); + return $this->createPayPalCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload); } - return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload); + return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload); } /** * @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload + * @param array $addon * @return array{checkout_url: string|null, id: string|null, expires_at: string|null} */ protected function createLemonSqueezyCheckout( Tenant $tenant, Event $event, string $addonKey, + array $addon, int $quantity, bool $acceptedTerms, bool $acceptedWaiver, @@ -72,14 +76,18 @@ class EventAddonCheckoutService if (! $variantId) { throw ValidationException::withMessages([ - 'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'), + 'addon_key' => __('Fuer dieses Add-on ist keine Lemon Squeezy Variant hinterlegt.'), ]); } $addonIntent = (string) Str::uuid(); $increments = $this->catalog->resolveIncrements($addonKey); + $addonLabel = $this->resolveAddonLabel($addonKey, $addon); + $addonMetadata = $this->resolveAddonMetadata($addon); + $entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata); + $price = $this->catalog->resolvePrice($addonKey); - $metadata = array_filter([ + $providerMetadata = array_filter([ 'tenant_id' => (string) $tenant->id, 'event_id' => (string) $event->id, 'event_package_id' => (string) $event->eventPackage->id, @@ -94,7 +102,7 @@ class EventAddonCheckoutService 'cancel_url' => $payload['cancel_url'] ?? null, ], static fn ($value) => $value !== null && $value !== ''); - $response = $this->checkout->createVariantCheckout($variantId, $metadata, [ + $response = $this->checkout->createVariantCheckout($variantId, $providerMetadata, [ 'success_url' => $payload['success_url'] ?? null, 'return_url' => $payload['cancel_url'] ?? null, 'customer_email' => $tenant->contact_email ?? $tenant->user?->email, @@ -117,15 +125,19 @@ class EventAddonCheckoutService 'checkout_id' => $checkoutId, 'transaction_id' => null, 'status' => 'pending', - 'metadata' => array_merge($metadata, [ + 'metadata' => array_filter(array_merge($providerMetadata, [ + 'label' => $addonLabel, + 'scope' => Arr::get($addonMetadata, 'scope'), + 'entitlements' => $entitlements === [] ? null : $entitlements, + 'price_eur' => $price, 'increments' => $increments, 'provider_payload' => $response, 'consents' => [ - 'legal_version' => $metadata['legal_version'], + 'legal_version' => $providerMetadata['legal_version'], 'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null, 'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null, ], - ]), + ]), static fn (mixed $value): bool => $value !== null), 'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity, 'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity, 'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity, @@ -140,23 +152,27 @@ class EventAddonCheckoutService /** * @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload + * @param array $addon * @return array{checkout_url: string|null, id: string|null, expires_at: string|null} */ protected function createPayPalCheckout( Tenant $tenant, Event $event, string $addonKey, + array $addon, int $quantity, bool $acceptedTerms, bool $acceptedWaiver, array $payload, ): array { $price = $this->catalog->resolvePrice($addonKey); - $addonLabel = $this->catalog->find($addonKey)['label'] ?? $addonKey; + $addonLabel = $this->resolveAddonLabel($addonKey, $addon); + $addonMetadata = $this->resolveAddonMetadata($addon); + $entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata); if (! $price) { throw ValidationException::withMessages([ - 'addon_key' => __('Dieses Add-on ist aktuell nicht verfügbar.'), + 'addon_key' => __('Dieses Add-on ist aktuell nicht verfuegbar.'), ]); } @@ -181,7 +197,7 @@ class EventAddonCheckoutService 'cancel_url' => $payload['cancel_url'] ?? null, ], static fn ($value) => $value !== null && $value !== ''); - $addon = EventPackageAddon::create([ + $addonPurchase = EventPackageAddon::create([ 'event_package_id' => $event->eventPackage->id, 'event_id' => $event->id, 'tenant_id' => $tenant->id, @@ -193,14 +209,18 @@ class EventAddonCheckoutService 'status' => 'pending', 'amount' => $amount, 'currency' => $currency, - 'metadata' => array_merge($metadata, [ + 'metadata' => array_filter(array_merge($metadata, [ + 'label' => $addonLabel, + 'scope' => Arr::get($addonMetadata, 'scope'), + 'entitlements' => $entitlements === [] ? null : $entitlements, + 'price_eur' => $price, 'increments' => $increments, 'consents' => [ 'legal_version' => $metadata['legal_version'], 'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null, 'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null, ], - ]), + ]), static fn (mixed $value): bool => $value !== null), 'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity, 'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity, 'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity, @@ -212,26 +232,26 @@ class EventAddonCheckoutService try { $order = $this->paypalOrders->createSimpleOrder( - referenceId: 'addon-'.$addon->id, + referenceId: 'addon-'.$addonPurchase->id, description: $addonLabel, amount: $amount, currency: $currency, options: [ - 'custom_id' => 'addon_'.$addon->id, + 'custom_id' => 'addon_'.$addonPurchase->id, 'return_url' => $paypalReturnUrl, 'cancel_url' => $paypalReturnUrl, 'locale' => $tenant->user?->preferred_locale ?? app()->getLocale(), - 'request_id' => 'addon-'.$addon->id, + 'request_id' => 'addon-'.$addonPurchase->id, ], ); } catch (PayPalException $exception) { Log::warning('PayPal addon checkout creation failed', [ - 'addon_id' => $addon->id, + 'addon_id' => $addonPurchase->id, 'message' => $exception->getMessage(), 'status' => $exception->status(), ]); - $addon->forceFill([ + $addonPurchase->forceFill([ 'status' => 'failed', 'error' => $exception->getMessage(), ])->save(); @@ -244,7 +264,7 @@ class EventAddonCheckoutService $orderId = $order['id'] ?? null; if (! is_string($orderId) || $orderId === '') { - $addon->forceFill([ + $addonPurchase->forceFill([ 'status' => 'failed', 'error' => 'PayPal order ID missing.', ])->save(); @@ -256,9 +276,9 @@ class EventAddonCheckoutService $approveUrl = $this->paypalOrders->resolveApproveUrl($order); - $addon->forceFill([ + $addonPurchase->forceFill([ 'checkout_id' => $orderId, - 'metadata' => array_merge($addon->metadata ?? [], array_filter([ + 'metadata' => array_merge($addonPurchase->metadata ?? [], array_filter([ 'paypal_order_id' => $orderId, 'paypal_approve_url' => $approveUrl, 'paypal_success_url' => $successUrl, @@ -291,4 +311,61 @@ class EventAddonCheckoutService { return config('app.legal_version', now()->toDateString()); } + + /** + * @param array $addon + */ + protected function resolveAddonLabel(string $addonKey, array $addon): string + { + $label = trim((string) ($addon['label'] ?? '')); + + return $label !== '' ? $label : $addonKey; + } + + /** + * @param array $addon + * @return array + */ + protected function resolveAddonMetadata(array $addon): array + { + $metadata = $addon['metadata'] ?? []; + + return is_array($metadata) ? $metadata : []; + } + + /** + * @param array $addonMetadata + * @return array + */ + protected function resolveAddonEntitlements(string $addonKey, array $addonMetadata): array + { + $entitlements = Arr::get($addonMetadata, 'entitlements', []); + $entitlements = is_array($entitlements) ? $entitlements : []; + + $features = Arr::get($entitlements, 'features', []); + $features = is_array($features) ? $features : []; + + if ($features === [] && in_array($addonKey, (array) config('ai-editing.entitlements.addon_keys', []), true)) { + $requiredFeature = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling')); + + if ($requiredFeature !== '') { + $features[] = $requiredFeature; + } + } + + $normalizedFeatures = array_values(array_unique(array_filter(array_map( + static fn (mixed $feature): string => trim((string) $feature), + $features, + )))); + + if ($normalizedFeatures !== []) { + $entitlements['features'] = $normalizedFeatures; + } + + if (is_string(Arr::get($entitlements, 'expires_at'))) { + $entitlements['expires_at'] = trim((string) $entitlements['expires_at']); + } + + return $entitlements; + } } diff --git a/app/Services/Addons/EventFeatureEntitlementService.php b/app/Services/Addons/EventFeatureEntitlementService.php new file mode 100644 index 00000000..d3fe45e8 --- /dev/null +++ b/app/Services/Addons/EventFeatureEntitlementService.php @@ -0,0 +1,174 @@ + $addonKeys + * @return array{ + * allowed: bool, + * granted_by: 'package'|'addon'|null, + * required_feature: string, + * addon_keys: array + * } + */ + public function resolveForEvent(Event $event, string $requiredFeature, array $addonKeys = []): array + { + $eventPackage = $this->resolveEventPackage($event); + + if (! $eventPackage) { + return [ + 'allowed' => false, + 'granted_by' => null, + 'required_feature' => $requiredFeature, + 'addon_keys' => $addonKeys, + ]; + } + + if ($this->packageGrantsFeature($eventPackage->package, $requiredFeature)) { + return [ + 'allowed' => true, + 'granted_by' => 'package', + 'required_feature' => $requiredFeature, + 'addon_keys' => $addonKeys, + ]; + } + + if ($this->addonGrantsFeature($eventPackage, $addonKeys, $requiredFeature)) { + return [ + 'allowed' => true, + 'granted_by' => 'addon', + 'required_feature' => $requiredFeature, + 'addon_keys' => $addonKeys, + ]; + } + + return [ + 'allowed' => false, + 'granted_by' => null, + 'required_feature' => $requiredFeature, + 'addon_keys' => $addonKeys, + ]; + } + + private function resolveEventPackage(Event $event): ?EventPackage + { + $event->loadMissing('eventPackage.package', 'eventPackage.addons'); + + if ($event->eventPackage) { + return $event->eventPackage; + } + + return $event->eventPackages() + ->with(['package', 'addons']) + ->orderByDesc('purchased_at') + ->orderByDesc('id') + ->first(); + } + + private function packageGrantsFeature(?Package $package, string $requiredFeature): bool + { + if (! $package) { + return false; + } + + return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true); + } + + /** + * @param array $addonKeys + */ + private function addonGrantsFeature(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool + { + $addons = $eventPackage->relationLoaded('addons') + ? $eventPackage->addons + : $eventPackage->addons() + ->where('status', 'completed') + ->get(); + + return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool { + if (! $this->addonIsActive($addon)) { + return false; + } + + if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) { + return true; + } + + $metadataFeatures = $this->normalizeFeatureList( + Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', [])) + ); + + return in_array($requiredFeature, $metadataFeatures, true); + }); + } + + private function addonIsActive(EventPackageAddon $addon): bool + { + if ($addon->status !== 'completed') { + return false; + } + + $expiryCandidates = [ + Arr::get($addon->metadata ?? [], 'entitlements.expires_at'), + Arr::get($addon->metadata ?? [], 'expires_at'), + Arr::get($addon->metadata ?? [], 'valid_until'), + ]; + + foreach ($expiryCandidates as $candidate) { + if (! is_string($candidate) || trim($candidate) === '') { + continue; + } + + try { + $expiresAt = CarbonImmutable::parse($candidate); + } catch (\Throwable) { + continue; + } + + if ($expiresAt->isPast()) { + return false; + } + } + + return true; + } + + /** + * @return array + */ + private function normalizeFeatureList(mixed $value): array + { + if (is_string($value)) { + $decoded = json_decode($value, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $value = $decoded; + } + } + + if (! is_array($value)) { + return []; + } + + if (array_is_list($value)) { + return array_values(array_filter(array_map( + static fn (mixed $feature): string => trim((string) $feature), + $value + ))); + } + + return array_values(array_filter(array_map( + static fn (mixed $feature): string => trim((string) $feature), + array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)) + ))); + } +} diff --git a/app/Services/AiEditing/AiStylingEntitlementService.php b/app/Services/AiEditing/AiStylingEntitlementService.php index 1eb71b4a..12434c17 100644 --- a/app/Services/AiEditing/AiStylingEntitlementService.php +++ b/app/Services/AiEditing/AiStylingEntitlementService.php @@ -3,14 +3,12 @@ namespace App\Services\AiEditing; use App\Models\Event; -use App\Models\EventPackage; -use App\Models\EventPackageAddon; -use App\Models\Package; -use Carbon\CarbonImmutable; -use Illuminate\Support\Arr; +use App\Services\Addons\EventFeatureEntitlementService; class AiStylingEntitlementService { + public function __construct(private readonly EventFeatureEntitlementService $featureEntitlements) {} + public function packageFeatureKey(): string { $featureKey = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling')); @@ -50,160 +48,15 @@ class AiStylingEntitlementService */ public function resolveForEvent(Event $event): array { - $requiredFeature = $this->packageFeatureKey(); - $addonKeys = $this->addonKeys(); - $eventPackage = $this->resolveEventPackage($event); - - if (! $eventPackage) { - return [ - 'allowed' => false, - 'granted_by' => null, - 'required_feature' => $requiredFeature, - 'addon_keys' => $addonKeys, - ]; - } - - if ($this->packageGrantsAccess($eventPackage->package, $requiredFeature)) { - return [ - 'allowed' => true, - 'granted_by' => 'package', - 'required_feature' => $requiredFeature, - 'addon_keys' => $addonKeys, - ]; - } - - if ($this->addonGrantsAccess($eventPackage, $addonKeys, $requiredFeature)) { - return [ - 'allowed' => true, - 'granted_by' => 'addon', - 'required_feature' => $requiredFeature, - 'addon_keys' => $addonKeys, - ]; - } - - return [ - 'allowed' => false, - 'granted_by' => null, - 'required_feature' => $requiredFeature, - 'addon_keys' => $addonKeys, - ]; + return $this->featureEntitlements->resolveForEvent( + $event, + $this->packageFeatureKey(), + $this->addonKeys(), + ); } public function hasAccessForEvent(Event $event): bool { return (bool) $this->resolveForEvent($event)['allowed']; } - - private function resolveEventPackage(Event $event): ?EventPackage - { - $event->loadMissing('eventPackage.package', 'eventPackage.addons'); - - if ($event->eventPackage) { - return $event->eventPackage; - } - - return $event->eventPackages() - ->with(['package', 'addons']) - ->orderByDesc('purchased_at') - ->orderByDesc('id') - ->first(); - } - - private function packageGrantsAccess(?Package $package, string $requiredFeature): bool - { - if (! $package) { - return false; - } - - return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true); - } - - /** - * @param array $addonKeys - */ - private function addonGrantsAccess(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool - { - $addons = $eventPackage->relationLoaded('addons') - ? $eventPackage->addons - : $eventPackage->addons() - ->where('status', 'completed') - ->get(); - - return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool { - if (! $this->addonIsActive($addon)) { - return false; - } - - if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) { - return true; - } - - $metadataFeatures = $this->normalizeFeatureList( - Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', [])) - ); - - return in_array($requiredFeature, $metadataFeatures, true); - }); - } - - private function addonIsActive(EventPackageAddon $addon): bool - { - if ($addon->status !== 'completed') { - return false; - } - - $expiryCandidates = [ - Arr::get($addon->metadata ?? [], 'entitlements.expires_at'), - Arr::get($addon->metadata ?? [], 'expires_at'), - Arr::get($addon->metadata ?? [], 'valid_until'), - ]; - - foreach ($expiryCandidates as $candidate) { - if (! is_string($candidate) || trim($candidate) === '') { - continue; - } - - try { - $expiresAt = CarbonImmutable::parse($candidate); - } catch (\Throwable) { - continue; - } - - if ($expiresAt->isPast()) { - return false; - } - } - - return true; - } - - /** - * @return array - */ - private function normalizeFeatureList(mixed $value): array - { - if (is_string($value)) { - $decoded = json_decode($value, true); - - if (json_last_error() === JSON_ERROR_NONE) { - $value = $decoded; - } - } - - if (! is_array($value)) { - return []; - } - - if (array_is_list($value)) { - return array_values(array_filter(array_map( - static fn (mixed $feature): string => trim((string) $feature), - $value - ))); - } - - return array_values(array_filter(array_map( - static fn (mixed $feature): string => trim((string) $feature), - array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled)) - ))); - } } diff --git a/composer.json b/composer.json index 1169af0f..89c44a80 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ "filament/filament": "~4.0", "firebase/php-jwt": "^6.11", "gboquizosanchez/filament-log-viewer": "*", - "guava/filament-knowledge-base": "^2.1", + "guava/filament-knowledge-base": "2", "inertiajs/inertia-laravel": "^2.0", "laravel/framework": "^12.0", "laravel/horizon": "^5.37", diff --git a/composer.lock b/composer.lock index 7ed13d41..49d9fb85 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "710c9b2af62e3768484530129f86a63d", + "content-hash": "81bcef3212aac95b1ef253c5fd14b8aa", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -1965,16 +1965,16 @@ }, { "name": "guava/filament-knowledge-base", - "version": "2.1.2", + "version": "2.0.0", "source": { "type": "git", "url": "https://github.com/GuavaCZ/filament-knowledge-base.git", - "reference": "cf62a0e526407b80c4fbcd0be6a6af508460d53d" + "reference": "88affb759afd739a640c9629dbf828b7557d3c8c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/GuavaCZ/filament-knowledge-base/zipball/cf62a0e526407b80c4fbcd0be6a6af508460d53d", - "reference": "cf62a0e526407b80c4fbcd0be6a6af508460d53d", + "url": "https://api.github.com/repos/GuavaCZ/filament-knowledge-base/zipball/88affb759afd739a640c9629dbf828b7557d3c8c", + "reference": "88affb759afd739a640c9629dbf828b7557d3c8c", "shasum": "" }, "require": { @@ -1990,15 +1990,16 @@ "symfony/yaml": "^7.0" }, "require-dev": { - "larastan/larastan": "^3.0", "laravel/pint": "^1.0", "nunomaduro/collision": "^8.0", + "nunomaduro/larastan": "^2.0.1", "orchestra/testbench": "^9.0|^10.0", - "pestphp/pest": "^3.0", - "pestphp/pest-plugin-arch": "^3.0", - "pestphp/pest-plugin-laravel": "^3.0", - "phpstan/phpstan-deprecation-rules": "^2.0", - "phpstan/phpstan-phpunit": "^2.0" + "pestphp/pest": "^2.0", + "pestphp/pest-plugin-arch": "^2.0", + "pestphp/pest-plugin-laravel": "^2.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1.0" }, "type": "library", "extra": { @@ -2037,7 +2038,7 @@ ], "support": { "issues": "https://github.com/GuavaCZ/filament-knowledge-base/issues", - "source": "https://github.com/GuavaCZ/filament-knowledge-base/tree/2.1.2" + "source": "https://github.com/GuavaCZ/filament-knowledge-base/tree/2.0.0" }, "funding": [ { @@ -2045,7 +2046,7 @@ "type": "github" } ], - "time": "2026-01-25T07:48:36+00:00" + "time": "2025-10-02T20:15:48+00:00" }, { "name": "guzzlehttp/guzzle", @@ -13399,5 +13400,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.6.0" + "plugin-api-version": "2.9.0" } diff --git a/config/package-addons.php b/config/package-addons.php index 40d95a84..0682d915 100644 --- a/config/package-addons.php +++ b/config/package-addons.php @@ -38,5 +38,11 @@ return [ 'price' => (float) env('ADDON_AI_STYLING_PRICE', 9.00), 'currency' => 'EUR', 'increments' => [], + 'metadata' => [ + 'scope' => 'feature', + 'entitlements' => [ + 'features' => ['ai_styling'], + ], + ], ], ]; diff --git a/database/migrations/2026_02_07_113926_ensure_ai_styling_addon_exists_in_package_addons_table.php b/database/migrations/2026_02_07_113926_ensure_ai_styling_addon_exists_in_package_addons_table.php new file mode 100644 index 00000000..ee971a5c --- /dev/null +++ b/database/migrations/2026_02_07_113926_ensure_ai_styling_addon_exists_in_package_addons_table.php @@ -0,0 +1,94 @@ +where('key', 'ai_styling_unlock') + ->first(); + + $metadata = $this->mergeMetadata($existing?->metadata ?? null); + + if (! $existing) { + DB::table('package_addons')->insert([ + 'key' => 'ai_styling_unlock', + 'label' => 'AI Styling Add-on', + 'variant_id' => null, + 'extra_photos' => 0, + 'extra_guests' => 0, + 'extra_gallery_days' => 0, + 'active' => true, + 'sort' => 42, + 'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return; + } + + DB::table('package_addons') + ->where('id', $existing->id) + ->update([ + 'label' => $existing->label ?: 'AI Styling Add-on', + 'sort' => $existing->sort ?: 42, + 'metadata' => json_encode($metadata, JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]); + } + + public function down(): void + { + // + } + + /** + * @return array + */ + private function mergeMetadata(mixed $metadata): array + { + if (is_string($metadata)) { + $decoded = json_decode($metadata, true); + + if (json_last_error() === JSON_ERROR_NONE) { + $metadata = $decoded; + } + } + + $metadata = is_array($metadata) ? $metadata : []; + $entitlements = Arr::get($metadata, 'entitlements'); + $entitlements = is_array($entitlements) ? $entitlements : []; + $features = Arr::get($entitlements, 'features', []); + $features = is_array($features) ? $features : []; + $features[] = 'ai_styling'; + + $entitlements['features'] = array_values(array_unique(array_filter(array_map( + static fn (mixed $feature): string => trim((string) $feature), + $features, + )))); + + if (! is_numeric(Arr::get($metadata, 'price_eur'))) { + $metadata['price_eur'] = 9; + } + + if (! is_string(Arr::get($metadata, 'scope')) || trim((string) Arr::get($metadata, 'scope')) === '') { + $metadata['scope'] = 'feature'; + } + + $metadata['entitlements'] = $entitlements; + + return $metadata; + } +}; diff --git a/database/seeders/PackageAddonSeeder.php b/database/seeders/PackageAddonSeeder.php index 649dde6b..73e9e9d8 100644 --- a/database/seeders/PackageAddonSeeder.php +++ b/database/seeders/PackageAddonSeeder.php @@ -142,6 +142,7 @@ class PackageAddonSeeder extends Seeder 'sort' => 42, 'metadata' => [ 'price_eur' => 9, + 'scope' => 'feature', 'entitlements' => [ 'features' => ['ai_styling'], ], diff --git a/public/js/guava/filament-knowledge-base/components/anchors-component.js b/public/js/guava/filament-knowledge-base/components/anchors-component.js new file mode 100644 index 00000000..c2c0c755 --- /dev/null +++ b/public/js/guava/filament-knowledge-base/components/anchors-component.js @@ -0,0 +1,37 @@ +// resources/js/anchors-component.js +function anchorsComponent() { + return { + init: async function() { + let anchors = document.querySelectorAll(".gu-kb-anchor"); + let settings = { + root: null, + rootMargin: "-15% 0px -65% 0px", + threshold: 0.1 + }; + let observer = new IntersectionObserver(this.callback, settings); + anchors.forEach((anchor) => observer.observe(anchor)); + }, + callback: function(entries, observer) { + let classes = [ + "transition", + "duration-300", + "ease-out", + "text-primary-600", + "dark:text-primary-400", + "translate-x-1" + ]; + entries.forEach((entry) => { + if (entry.isIntersecting) { + let section = "#" + entry.target.id; + document.querySelectorAll(".fi-sidebar-item-button .fi-sidebar-item-label").forEach((el2) => el2.classList.remove(...classes)); + let el = document.querySelector(".fi-sidebar-item-button[href='" + section + "'] .fi-sidebar-item-label"); + el.classList.add(...classes); + } + }); + } + }; +} +export { + anchorsComponent as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vcmVzb3VyY2VzL2pzL2FuY2hvcnMtY29tcG9uZW50LmpzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJleHBvcnQgZGVmYXVsdCBmdW5jdGlvbiBhbmNob3JzQ29tcG9uZW50KCkge1xuICAgIHJldHVybiB7XG5cbiAgICAgICAgaW5pdDogYXN5bmMgZnVuY3Rpb24gKCkge1xuICAgICAgICAgICAgbGV0IGFuY2hvcnMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcuZ3Uta2ItYW5jaG9yJyk7XG5cbiAgICAgICAgICAgIGxldCBzZXR0aW5ncyA9IHtcbiAgICAgICAgICAgICAgICByb290OiBudWxsLFxuICAgICAgICAgICAgICAgIHJvb3RNYXJnaW46ICctMTUlIDBweCAtNjUlIDBweCcsXG4gICAgICAgICAgICAgICAgdGhyZXNob2xkOiAwLjEsXG4gICAgICAgICAgICB9O1xuXG4gICAgICAgICAgICBsZXQgb2JzZXJ2ZXIgPSBuZXcgSW50ZXJzZWN0aW9uT2JzZXJ2ZXIodGhpcy5jYWxsYmFjaywgc2V0dGluZ3MpO1xuXG4gICAgICAgICAgICBhbmNob3JzLmZvckVhY2goYW5jaG9yID0+IG9ic2VydmVyLm9ic2VydmUoYW5jaG9yKSk7XG4gICAgICAgIH0sXG5cbiAgICAgICAgY2FsbGJhY2s6IGZ1bmN0aW9uIChlbnRyaWVzLCBvYnNlcnZlcikge1xuICAgICAgICAgICAgbGV0IGNsYXNzZXMgPSBbXG4gICAgICAgICAgICAgICAgJ3RyYW5zaXRpb24nLCAnZHVyYXRpb24tMzAwJywgJ2Vhc2Utb3V0JywgJ3RleHQtcHJpbWFyeS02MDAnLCAnZGFyazp0ZXh0LXByaW1hcnktNDAwJywgJ3RyYW5zbGF0ZS14LTEnXG4gICAgICAgICAgICBdO1xuXG4gICAgICAgICAgICBlbnRyaWVzLmZvckVhY2goZW50cnkgPT4ge1xuICAgICAgICAgICAgICAgIGlmIChlbnRyeS5pc0ludGVyc2VjdGluZykge1xuICAgICAgICAgICAgICAgICAgICBsZXQgc2VjdGlvbiA9ICcjJyArIGVudHJ5LnRhcmdldC5pZDtcbiAgICAgICAgICAgICAgICAgICAgZG9jdW1lbnQucXVlcnlTZWxlY3RvckFsbCgnLmZpLXNpZGViYXItaXRlbS1idXR0b24gLmZpLXNpZGViYXItaXRlbS1sYWJlbCcpXG4gICAgICAgICAgICAgICAgICAgICAgICAuZm9yRWFjaCgoZWwpID0+IGVsLmNsYXNzTGlzdC5yZW1vdmUoLi4uY2xhc3NlcykpO1xuICAgICAgICAgICAgICAgICAgICBsZXQgZWwgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCcuZmktc2lkZWJhci1pdGVtLWJ1dHRvbltocmVmPVxcJycgKyBzZWN0aW9uICsgJ1xcJ10gLmZpLXNpZGViYXItaXRlbS1sYWJlbCcpO1xuICAgICAgICAgICAgICAgICAgICBlbC5jbGFzc0xpc3QuYWRkKC4uLmNsYXNzZXMpO1xuICAgICAgICAgICAgICAgIH1cbiAgICAgICAgICAgIH0pO1xuICAgICAgICB9XG4gICAgfVxuXG59O1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUFlLFNBQVIsbUJBQW9DO0FBQ3ZDLFNBQU87QUFBQSxJQUVILE1BQU0saUJBQWtCO0FBQ3BCLFVBQUksVUFBVSxTQUFTLGlCQUFpQixlQUFlO0FBRXZELFVBQUksV0FBVztBQUFBLFFBQ1gsTUFBTTtBQUFBLFFBQ04sWUFBWTtBQUFBLFFBQ1osV0FBVztBQUFBLE1BQ2Y7QUFFQSxVQUFJLFdBQVcsSUFBSSxxQkFBcUIsS0FBSyxVQUFVLFFBQVE7QUFFL0QsY0FBUSxRQUFRLFlBQVUsU0FBUyxRQUFRLE1BQU0sQ0FBQztBQUFBLElBQ3REO0FBQUEsSUFFQSxVQUFVLFNBQVUsU0FBUyxVQUFVO0FBQ25DLFVBQUksVUFBVTtBQUFBLFFBQ1Y7QUFBQSxRQUFjO0FBQUEsUUFBZ0I7QUFBQSxRQUFZO0FBQUEsUUFBb0I7QUFBQSxRQUF5QjtBQUFBLE1BQzNGO0FBRUEsY0FBUSxRQUFRLFdBQVM7QUFDckIsWUFBSSxNQUFNLGdCQUFnQjtBQUN0QixjQUFJLFVBQVUsTUFBTSxNQUFNLE9BQU87QUFDakMsbUJBQVMsaUJBQWlCLGdEQUFnRCxFQUNyRSxRQUFRLENBQUNBLFFBQU9BLElBQUcsVUFBVSxPQUFPLEdBQUcsT0FBTyxDQUFDO0FBQ3BELGNBQUksS0FBSyxTQUFTLGNBQWMsbUNBQW9DLFVBQVUsMkJBQTRCO0FBQzFHLGFBQUcsVUFBVSxJQUFJLEdBQUcsT0FBTztBQUFBLFFBQy9CO0FBQUEsTUFDSixDQUFDO0FBQUEsSUFDTDtBQUFBLEVBQ0o7QUFFSjsiLAogICJuYW1lcyI6IFsiZWwiXQp9Cg== diff --git a/public/js/guava/filament-knowledge-base/components/modals-component.js b/public/js/guava/filament-knowledge-base/components/modals-component.js new file mode 100644 index 00000000..64229266 --- /dev/null +++ b/public/js/guava/filament-knowledge-base/components/modals-component.js @@ -0,0 +1,23 @@ +// resources/js/modals-component.js +function modalsComponent() { + return { + init: async function() { + this.hashChanged(); + window.addEventListener("hashchange", () => this.hashChanged()); + }, + hashChanged: function() { + const fragment = location.hash.substring(1); + const prefix = "modal-"; + if (fragment.startsWith(prefix)) { + const modal = fragment.substring(fragment.indexOf(prefix) + prefix.length); + console.log("Open modal via wire: ", modal); + this.$wire.showDocumentation(modal); + history.replaceState(null, null, " "); + } + } + }; +} +export { + modalsComponent as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vcmVzb3VyY2VzL2pzL21vZGFscy1jb21wb25lbnQuanMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImV4cG9ydCBkZWZhdWx0IGZ1bmN0aW9uIG1vZGFsc0NvbXBvbmVudCgpIHtcbiAgICByZXR1cm4ge1xuXG4gICAgICAgIGluaXQ6IGFzeW5jIGZ1bmN0aW9uICgpIHtcbiAgICAgICAgICAgIHRoaXMuaGFzaENoYW5nZWQoKTtcbiAgICAgICAgICAgIHdpbmRvdy5hZGRFdmVudExpc3RlbmVyKCdoYXNoY2hhbmdlJywgKCk9PnRoaXMuaGFzaENoYW5nZWQoKSk7XG4gICAgICAgIH0sXG5cbiAgICAgICAgaGFzaENoYW5nZWQ6IGZ1bmN0aW9uKCkge1xuICAgICAgICAgICAgY29uc3QgZnJhZ21lbnQgPSBsb2NhdGlvbi5oYXNoLnN1YnN0cmluZygxKTtcbiAgICAgICAgICAgIGNvbnN0IHByZWZpeCA9ICdtb2RhbC0nO1xuXG4gICAgICAgICAgICBpZiAoZnJhZ21lbnQuc3RhcnRzV2l0aChwcmVmaXgpKSB7XG4gICAgICAgICAgICAgICAgY29uc3QgbW9kYWwgPSBmcmFnbWVudC5zdWJzdHJpbmcoZnJhZ21lbnQuaW5kZXhPZihwcmVmaXgpICsgcHJlZml4Lmxlbmd0aCk7XG5cbiAgICAgICAgICAgICAgICBjb25zb2xlLmxvZygnT3BlbiBtb2RhbCB2aWEgd2lyZTogJywgbW9kYWwpO1xuICAgICAgICAgICAgICAgIHRoaXMuJHdpcmUuc2hvd0RvY3VtZW50YXRpb24obW9kYWwpO1xuICAgICAgICAgICAgICAgIC8vIHdpbmRvdy5kaXNwYXRjaEV2ZW50KG5ldyBDdXN0b21FdmVudCgnb3Blbi1tb2RhbCcsIHtkZXRhaWw6IHtpZDogbW9kYWx9fSkpO1xuICAgICAgICAgICAgICAgIGhpc3RvcnkucmVwbGFjZVN0YXRlKG51bGwsIG51bGwsICcgJyk7XG4gICAgICAgICAgICB9XG4gICAgICAgIH0sXG5cbiAgICB9XG5cbn07XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWUsU0FBUixrQkFBbUM7QUFDdEMsU0FBTztBQUFBLElBRUgsTUFBTSxpQkFBa0I7QUFDcEIsV0FBSyxZQUFZO0FBQ2pCLGFBQU8saUJBQWlCLGNBQWMsTUFBSSxLQUFLLFlBQVksQ0FBQztBQUFBLElBQ2hFO0FBQUEsSUFFQSxhQUFhLFdBQVc7QUFDcEIsWUFBTSxXQUFXLFNBQVMsS0FBSyxVQUFVLENBQUM7QUFDMUMsWUFBTSxTQUFTO0FBRWYsVUFBSSxTQUFTLFdBQVcsTUFBTSxHQUFHO0FBQzdCLGNBQU0sUUFBUSxTQUFTLFVBQVUsU0FBUyxRQUFRLE1BQU0sSUFBSSxPQUFPLE1BQU07QUFFekUsZ0JBQVEsSUFBSSx5QkFBeUIsS0FBSztBQUMxQyxhQUFLLE1BQU0sa0JBQWtCLEtBQUs7QUFFbEMsZ0JBQVEsYUFBYSxNQUFNLE1BQU0sR0FBRztBQUFBLE1BQ3hDO0FBQUEsSUFDSjtBQUFBLEVBRUo7QUFFSjsiLAogICJuYW1lcyI6IFtdCn0K diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 3090f653..c5c758b1 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -1990,7 +1990,14 @@ export async function getEventAiEditSummary(slug: string): Promise { const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, { method: 'POST', @@ -2656,6 +2663,8 @@ export type Package = { included_package_slug?: string | null; lemonsqueezy_variant_id?: string | null; lemonsqueezy_product_id?: string | null; + can_checkout?: boolean; + checkout_provider?: 'paypal' | 'lemonsqueezy' | string; branding_allowed?: boolean | null; watermark_allowed?: boolean | null; features: string[] | Record | null; diff --git a/resources/js/admin/mobile/BillingPage.tsx b/resources/js/admin/mobile/BillingPage.tsx index bd9b2511..ee8a7b1c 100644 --- a/resources/js/admin/mobile/BillingPage.tsx +++ b/resources/js/admin/mobile/BillingPage.tsx @@ -14,10 +14,12 @@ import { getTenantPackagesOverview, getTenantPackageCheckoutStatus, getEvent, + getAddonCatalog, TenantPackageSummary, TenantEvent, TenantBillingTransactionSummary, EventAddonSummary, + EventAddonCatalogItem, PaginationMeta, downloadTenantBillingReceipt, } from '../api'; @@ -76,6 +78,7 @@ export default function MobileBillingPage() { ); const [transactionsLoadingMore, setTransactionsLoadingMore] = React.useState(false); const [addons, setAddons] = React.useState([]); + const [catalogAddons, setCatalogAddons] = React.useState([]); const [addonsMeta, setAddonsMeta] = React.useState(() => createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE) ); @@ -109,7 +112,7 @@ export default function MobileBillingPage() { const load = React.useCallback(async () => { setLoading(true); try { - const [pkg, trx, addonHistory] = await Promise.all([ + const [pkg, trx, addonHistory, addonCatalog] = await Promise.all([ getTenantPackagesOverview({ force: true }), getTenantBillingTransactions(1, BILLING_HISTORY_PAGE_SIZE).catch(() => ({ data: [] as TenantBillingTransactionSummary[], @@ -119,6 +122,7 @@ export default function MobileBillingPage() { data: [] as TenantAddonHistoryEntry[], meta: createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE), })), + getAddonCatalog().catch(() => [] as EventAddonCatalogItem[]), ]); let scopedEvent: TenantEvent | null = null; @@ -153,6 +157,7 @@ export default function MobileBillingPage() { setTransactions(trx.data ?? []); setTransactionsMeta(trx.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)); setAddons(addonHistory.data ?? []); + setCatalogAddons(addonCatalog); setAddonsMeta(addonHistory.meta ?? createInitialPaginationMeta(BILLING_HISTORY_PAGE_SIZE)); setScopeEvent(scopedEvent); setScopeAddons(scopedAddons); @@ -195,6 +200,9 @@ export default function MobileBillingPage() { }, [scopeEvent, t]); const scopedEventPath = scopeEvent?.slug ? ADMIN_EVENT_VIEW_PATH(scopeEvent.slug) : null; const activeEventId = scopeEvent?.id ?? activeEvent?.id ?? null; + const hasSellableAddons = React.useMemo(() => catalogAddons.some((addon) => Boolean(addon.price_id)), [catalogAddons]); + const eventRecapPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/recap`) : null; + const eventControlRoomPath = scopeEvent?.slug ? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`) : null; const scopedEventPackage = scopeEvent?.package ?? null; const scopedEventAddons = React.useMemo(() => { const rows = scopeEvent?.addons; @@ -689,6 +697,25 @@ export default function MobileBillingPage() { {t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')} )} + {scopeEvent?.slug ? ( + hasSellableAddons ? ( + + navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))} + /> + navigate(eventControlRoomPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`))} + /> + + ) : ( + + {t('billing.sections.currentEvent.noAddonCatalog', 'No add-ons are currently available for purchase.')} + + ) + ) : null} )} @@ -862,9 +889,17 @@ export default function MobileBillingPage() { {t('common.loading', 'Lädt...')} ) : addons.length === 0 ? ( - - {t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')} - + + + {t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')} + + {scopeEvent?.slug && hasSellableAddons ? ( + navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))} + /> + ) : null} + ) : ( {addons.map((addon) => ( diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 392e6843..e4d59608 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -434,6 +434,24 @@ export default function MobileEventControlRoomPage() { const aiStylingAddon = React.useMemo(() => { return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null; }, [aiStylingAddonKeys, catalogAddons]); + const aiStylingAddonCta = React.useMemo(() => { + if (!aiStylingAddon) { + return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit'); + } + + if (typeof aiStylingAddon.price !== 'number' || !Number.isFinite(aiStylingAddon.price)) { + return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit'); + } + + const currency = aiStylingAddon.currency || 'EUR'; + + try { + const formattedPrice = new Intl.NumberFormat(undefined, { style: 'currency', currency }).format(aiStylingAddon.price); + return t('controlRoom.aiUpsell.ctaWithPrice', 'Unlock AI Magic Edit ({{price}})', { price: formattedPrice }); + } catch { + return t('controlRoom.aiUpsell.cta', 'Unlock AI Magic Edit'); + } + }, [aiStylingAddon, t]); const aiSettingsDirty = React.useMemo( () => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft), [aiSettingsDraft, initialAiSettingsDraft], @@ -907,12 +925,13 @@ export default function MobileEventControlRoomPage() { const params = new URLSearchParams(location.search); if (params.get('addon_success')) { toast.success(t('mobileBilling.addonApplied', 'Add-on applied. Limits update shortly.')); + void refetch(); setModerationPage(1); void loadModeration(); params.delete('addon_success'); navigate({ pathname: location.pathname, search: params.toString() ? `?${params.toString()}` : '' }, { replace: true }); } - }, [location.search, slug, loadModeration, navigate, t, location.pathname]); + }, [location.search, slug, loadModeration, navigate, t, location.pathname, refetch]); const updateQueueState = React.useCallback((queue: PhotoModerationAction[]) => { replacePhotoQueue(queue); @@ -1161,7 +1180,7 @@ export default function MobileEventControlRoomPage() { cancel_url: currentUrl, accepted_terms: consents.acceptedTerms, accepted_waiver: consents.acceptedWaiver, - } as any); + }); if (checkout.checkout_url) { window.location.href = checkout.checkout_url; } else { @@ -1832,6 +1851,27 @@ export default function MobileEventControlRoomPage() { /> + ) : aiStylingAddon ? ( + + + + + {t('controlRoom.aiUpsell.title', 'Unlock AI Magic Edit')} + + + + {t( + 'controlRoom.aiUpsell.body', + 'Buy the AI Styling add-on for this event without upgrading your full package.' + )} + + startAddonCheckout('ai')} + loading={busyScope === 'ai'} + disabled={consentBusy} + /> + ) : null ) : null} diff --git a/resources/js/admin/mobile/EventRecapPage.tsx b/resources/js/admin/mobile/EventRecapPage.tsx index 10a30ff6..096269c3 100644 --- a/resources/js/admin/mobile/EventRecapPage.tsx +++ b/resources/js/admin/mobile/EventRecapPage.tsx @@ -130,25 +130,16 @@ export default function MobileEventRecapPage() { }; }, [event?.status, i18n.language, invites, t]); - const handleCheckout = async (addonKey: string) => { - if (!slug || busyScope) return; - setBusyScope(addonKey); - try { - const { checkout_url } = await createEventAddonCheckout(slug, { - addon_key: addonKey, - success_url: window.location.href, - cancel_url: window.location.href, - }); - if (checkout_url) { - window.location.href = checkout_url; - } - } catch (err) { - toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.'))); - setBusyScope(null); + const handleCheckout = (addonKey: string) => { + if (!slug || busyScope) { + return; } + + setBusyScope(addonKey); + setConsentOpen(true); }; - const handleConsentConfirm = async (consents: { acceptedTerms: boolean }) => { + const handleConsentConfirm = async (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => { if (!slug || !busyScope) return; try { const { checkout_url } = await createEventAddonCheckout(slug, { @@ -156,10 +147,16 @@ export default function MobileEventRecapPage() { success_url: window.location.href, cancel_url: window.location.href, accepted_terms: consents.acceptedTerms, - } as any); + accepted_waiver: consents.acceptedWaiver, + }); if (checkout_url) { window.location.href = checkout_url; + return; } + + toast.error(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.')); + setBusyScope(null); + setConsentOpen(false); } catch (err) { toast.error(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Bezahlvorgang konnte nicht gestartet werden.'))); setBusyScope(null); @@ -200,6 +197,9 @@ export default function MobileEventRecapPage() { const activeInvite = invites.find((i) => i.is_active) ?? invites[0] ?? null; const guestLink = activeInvite?.url ?? ''; + const galleryExtensionAddons = addons + .filter((addon) => addon.key.startsWith('extend_gallery_') || Number(addon.increments?.extra_gallery_days ?? 0) > 0) + .sort((left, right) => Number(left.increments?.extra_gallery_days ?? 0) - Number(right.increments?.extra_gallery_days ?? 0)); return ( - {addons - .filter((a) => a.key === 'gallery_extension') - .map((addon) => ( - handleCheckout(addon.key)} - loading={busyScope === addon.key} - /> - ))} + {galleryExtensionAddons.map((addon) => ( + handleCheckout(addon.key)} + loading={busyScope === addon.key} + /> + ))} + {galleryExtensionAddons.length === 0 ? ( + + {t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')} + + ) : null} diff --git a/resources/js/admin/mobile/PackageShopPage.tsx b/resources/js/admin/mobile/PackageShopPage.tsx index b2b3f4a9..17ca70aa 100644 --- a/resources/js/admin/mobile/PackageShopPage.tsx +++ b/resources/js/admin/mobile/PackageShopPage.tsx @@ -224,7 +224,7 @@ function PackageShopCard({ const isResellerCatalog = catalogType === 'reseller'; const statusLabel = getPackageStatusLabel({ t, isActive, owned }); const isSubdued = Boolean(!isResellerCatalog && (isDowngrade || !isUpgrade) && !isActive); - const canSelect = isResellerCatalog ? Boolean(pkg.lemonsqueezy_variant_id) : canSelectPackage(isUpgrade, isActive); + const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(pkg) : canSelectPackage(isUpgrade, isActive); const hasManageAction = Boolean(isActive && onManage); const includedTierLabel = resolveIncludedTierLabel(t, pkg.included_package_slug ?? null); const handlePress = isActive ? onManage : canSelect ? onSelect : undefined; @@ -524,7 +524,7 @@ function PackageShopCompareView({ {entries.map((entry) => { const isResellerCatalog = catalogType === 'reseller'; - const canSelect = isResellerCatalog ? Boolean(entry.pkg.lemonsqueezy_variant_id) : canSelectPackage(entry.isUpgrade, entry.isActive); + const canSelect = isResellerCatalog ? isPackageCheckoutAvailable(entry.pkg) : canSelectPackage(entry.isUpgrade, entry.isActive); const label = isResellerCatalog ? canSelect ? t('shop.partner.buy', 'Kaufen') @@ -586,6 +586,14 @@ function canSelectPackage(isUpgrade?: boolean, isActive?: boolean): boolean { return Boolean(isActive || isUpgrade); } +function isPackageCheckoutAvailable(pkg: Package): boolean { + if (typeof pkg.can_checkout === 'boolean') { + return pkg.can_checkout; + } + + return true; +} + function CheckoutConfirmation({ pkg, onCancel }: { pkg: Package; onCancel: () => void }) { const { t } = useTranslation('management'); const { textStrong, muted, border, primary } = useAdminTheme(); diff --git a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx index 097d44c5..a1b3b543 100644 --- a/resources/js/admin/mobile/__tests__/BillingPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/BillingPage.test.tsx @@ -153,6 +153,7 @@ vi.mock('../invite-layout/export-utils', () => ({ vi.mock('../../api', () => ({ getEvent: vi.fn(), + getAddonCatalog: vi.fn().mockResolvedValue([]), getTenantPackagesOverview: vi.fn().mockResolvedValue({ packages: [], activePackage: null }), getTenantBillingTransactions: vi.fn().mockResolvedValue({ data: [ @@ -199,6 +200,7 @@ describe('MobileBillingPage', () => { } as any); vi.mocked(api.getTenantAddonHistory).mockResolvedValue({ data: [] } as any); vi.mocked(api.getEvent).mockResolvedValue(null as any); + vi.mocked(api.getAddonCatalog).mockResolvedValue([]); }); it('shows current event scoped entitlements separately from tenant history', async () => { @@ -480,4 +482,39 @@ describe('MobileBillingPage', () => { expect(triggerDownloadMock).toHaveBeenCalled(); }); }); + + it('offers a direct add-on purchase entry in billing for the selected event', async () => { + eventContext.activeEvent = { + id: 99, + slug: 'fruehlingsfest', + name: { de: 'Frühlingsfest' }, + }; + + vi.mocked(api.getEvent).mockResolvedValueOnce({ + id: 99, + slug: 'fruehlingsfest', + name: { de: 'Frühlingsfest' }, + event_date: null, + event_type_id: null, + event_type: null, + status: 'published', + settings: {}, + package: null, + addons: [], + limits: null, + member_permissions: [], + } as any); + vi.mocked(api.getAddonCatalog).mockResolvedValueOnce([ + { + key: 'extend_gallery_30d', + label: 'Galerie +30 Tage', + price_id: 'paypal', + increments: { extra_gallery_days: 30 }, + }, + ] as any); + + render(); + + expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument(); + }); }); diff --git a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx index 1055f8b7..0f10d936 100644 --- a/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventControlRoomPage.test.tsx @@ -219,6 +219,7 @@ vi.mock('../../api', () => ({ })); import MobileEventControlRoomPage from '../EventControlRoomPage'; +import * as api from '../../api'; describe('MobileEventControlRoomPage', () => { it('renders compact grid actions for moderation photos', async () => { @@ -228,4 +229,22 @@ describe('MobileEventControlRoomPage', () => { expect(screen.getByLabelText('Hide')).toBeInTheDocument(); expect(screen.getByLabelText('Set highlight')).toBeInTheDocument(); }); + + it('shows AI addon upsell when AI is locked but addon is purchasable', async () => { + (api.getAddonCatalog as any).mockResolvedValueOnce([ + { + key: 'ai_styling_unlock', + label: 'AI Styling Add-on', + price_id: 'paypal', + increments: {}, + price: 9, + currency: 'EUR', + }, + ]); + + render(); + + expect(await screen.findByText('Unlock AI Magic Edit')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Unlock AI Magic Edit/i })).toBeInTheDocument(); + }); }); diff --git a/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx index bd9df931..5a4bcd2b 100644 --- a/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventRecapPage.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { describe, expect, it, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; const fixtures = vi.hoisted(() => ({ event: { @@ -23,7 +23,14 @@ const fixtures = vi.hoisted(() => ({ is_active: true, }, invites: [], - addons: [], + addons: [ + { + key: 'extend_gallery_30d', + label: 'Galerie um 30 Tage verlängern', + price_id: 'price_30d', + increments: { extra_gallery_days: 30 }, + }, + ], })); vi.mock('react-router-dom', () => ({ @@ -60,7 +67,11 @@ vi.mock('../../api', () => ({ getEventQrInvites: vi.fn().mockResolvedValue(fixtures.invites), getAddonCatalog: vi.fn().mockResolvedValue(fixtures.addons), updateEvent: vi.fn().mockResolvedValue(fixtures.event), - createEventAddonCheckout: vi.fn(), + createEventAddonCheckout: vi.fn().mockResolvedValue({ + checkout_url: null, + checkout_id: 'chk_123', + expires_at: null, + }), getEventEngagement: vi.fn().mockResolvedValue({ summary: { totalPhotos: 0, uniqueGuests: 0, tasksSolved: 0, likesTotal: 0 }, leaderboards: { uploads: [], likes: [] }, @@ -87,7 +98,11 @@ vi.mock('../components/MobileShell', () => ({ vi.mock('../components/Primitives', () => ({ MobileCard: ({ children }: { children: React.ReactNode }) =>
{children}
, - CTAButton: ({ label }: { label: string }) => , + CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => ( + + ), PillBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, SkeletonCard: () =>
Loading...
, })); @@ -101,7 +116,13 @@ vi.mock('../components/FormControls', () => ({ })); vi.mock('../components/LegalConsentSheet', () => ({ - LegalConsentSheet: () =>
, + LegalConsentSheet: ({ + open, + onConfirm, + }: { + open: boolean; + onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void; + }) => (open ? : null), })); vi.mock('@tamagui/stacks', () => ({ @@ -164,8 +185,21 @@ vi.mock('../theme', () => ({ })); import MobileEventRecapPage from '../EventRecapPage'; +import { createEventAddonCheckout } from '../../api'; describe('MobileEventRecapPage', () => { + beforeEach(() => { + fixtures.addons = [ + { + key: 'extend_gallery_30d', + label: 'Galerie um 30 Tage verlängern', + price_id: 'price_30d', + increments: { extra_gallery_days: 30 }, + }, + ]; + vi.clearAllMocks(); + }); + it('renders recap settings toggles', async () => { render(); @@ -173,4 +207,26 @@ describe('MobileEventRecapPage', () => { expect(screen.getByLabelText('Gäste dürfen Fotos laden')).toBeInTheDocument(); expect(screen.getByLabelText('Gäste dürfen Fotos teilen')).toBeInTheDocument(); }); + + it('requires consent and sends both legal acceptance flags for recap addon checkout', async () => { + const checkoutMock = vi.mocked(createEventAddonCheckout); + render(); + + const checkoutButton = await screen.findByRole('button', { name: 'Galerie um 30 Tage verlängern' }); + fireEvent.click(checkoutButton); + + expect(checkoutMock).not.toHaveBeenCalled(); + + fireEvent.click(await screen.findByRole('button', { name: 'Confirm legal' })); + + await waitFor(() => { + expect(checkoutMock).toHaveBeenCalledWith(fixtures.event.slug, { + addon_key: 'extend_gallery_30d', + success_url: window.location.href, + cancel_url: window.location.href, + accepted_terms: true, + accepted_waiver: true, + }); + }); + }); }); diff --git a/routes/api.php b/routes/api.php index d1baadab..695dd55b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -301,7 +301,6 @@ 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('live-show')->group(function () { diff --git a/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php index b7265508..ffe11bd3 100644 --- a/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php +++ b/tests/Feature/Api/Tenant/BillingAddonHistoryTest.php @@ -6,6 +6,7 @@ use App\Models\Event; use App\Models\EventPackage; use App\Models\EventPackageAddon; use App\Models\Package; +use App\Models\PackageAddon; use App\Models\Tenant; use Tests\Feature\Tenant\TenantTestCase; @@ -225,4 +226,49 @@ class BillingAddonHistoryTest extends TenantTestCase $response->assertStatus(404); $response->assertJsonPath('message', 'Event scope not found.'); } + + public function test_tenant_addon_history_uses_catalog_label_when_metadata_label_missing(): void + { + $package = Package::factory()->endcustomer()->create(); + + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'catalog-label-event', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now()->subWeek(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(20), + ]); + + PackageAddon::create([ + 'key' => 'catalog_label_test', + 'label' => 'Catalog label fallback', + 'active' => true, + 'sort' => 5, + 'metadata' => ['price_eur' => 4], + ]); + + EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'catalog_label_test', + 'quantity' => 1, + 'status' => 'completed', + 'amount' => 4.00, + 'currency' => 'EUR', + 'metadata' => [], + 'purchased_at' => now(), + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons'); + + $response->assertOk(); + $response->assertJsonPath('data.0.label', 'Catalog label fallback'); + } } diff --git a/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php index 1160ee69..15dbcbed 100644 --- a/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php +++ b/tests/Feature/Api/Tenant/EventAddonsSummaryTest.php @@ -6,6 +6,7 @@ use App\Models\Event; use App\Models\EventPackage; use App\Models\EventPackageAddon; use App\Models\Package; +use App\Models\PackageAddon; use Tests\Feature\Tenant\TenantTestCase; class EventAddonsSummaryTest extends TenantTestCase @@ -87,4 +88,49 @@ class EventAddonsSummaryTest extends TenantTestCase $response->assertJsonPath('data.capabilities.ai_styling', true); $response->assertJsonPath('data.capabilities.ai_styling_granted_by', 'addon'); } + + public function test_event_resource_uses_catalog_label_when_addon_metadata_label_missing(): void + { + $package = Package::factory()->endcustomer()->create([ + 'features' => ['basic_uploads'], + ]); + + $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), + ]); + + PackageAddon::create([ + 'key' => 'catalog_label_summary_test', + 'label' => 'Catalog Summary Label', + 'active' => true, + 'sort' => 3, + 'metadata' => ['price_eur' => 3], + ]); + + EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $this->tenant->id, + 'addon_key' => 'catalog_label_summary_test', + 'quantity' => 1, + 'status' => 'completed', + 'purchased_at' => now(), + 'metadata' => [], + ]); + + $response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}"); + + $response->assertOk(); + $response->assertJsonPath('data.addons.0.label', 'Catalog Summary Label'); + } } diff --git a/tests/Feature/Tenant/EventAddonCatalogControllerTest.php b/tests/Feature/Tenant/EventAddonCatalogControllerTest.php new file mode 100644 index 00000000..7efd131e --- /dev/null +++ b/tests/Feature/Tenant/EventAddonCatalogControllerTest.php @@ -0,0 +1,79 @@ + 'extend_gallery_30d', + 'label' => 'Galerie +30 Tage', + 'active' => true, + 'sort' => 10, + 'extra_gallery_days' => 30, + 'metadata' => ['price_eur' => 4], + ]); + + PackageAddon::create([ + 'key' => 'extend_gallery_90d', + 'label' => 'Galerie +90 Tage', + 'active' => true, + 'sort' => 20, + 'extra_gallery_days' => 90, + 'variant_id' => 'variant_only', + 'metadata' => [], + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/addons/catalog'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'key' => 'extend_gallery_30d', + 'price_id' => 'paypal', + ]); + $response->assertJsonMissing([ + 'key' => 'extend_gallery_90d', + ]); + } + + public function test_lemonsqueezy_catalog_only_returns_addons_with_variant(): void + { + Config::set('package-addons.provider', 'lemonsqueezy'); + + PackageAddon::create([ + 'key' => 'extend_gallery_30d', + 'label' => 'Galerie +30 Tage', + 'active' => true, + 'sort' => 10, + 'extra_gallery_days' => 30, + 'variant_id' => 'var_30d', + 'metadata' => ['price_eur' => 4], + ]); + + PackageAddon::create([ + 'key' => 'extend_gallery_90d', + 'label' => 'Galerie +90 Tage', + 'active' => true, + 'sort' => 20, + 'extra_gallery_days' => 90, + 'metadata' => ['price_eur' => 10], + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/addons/catalog'); + + $response->assertOk(); + $response->assertJsonFragment([ + 'key' => 'extend_gallery_30d', + 'price_id' => 'var_30d', + ]); + $response->assertJsonMissing([ + 'key' => 'extend_gallery_90d', + ]); + } +} diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index 4306c3ea..b24d4821 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -84,6 +84,9 @@ class EventAddonCheckoutTest extends TenantTestCase $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); $this->assertSame(1000, $addon->extra_photos); // increments * quantity + $this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null); + $this->assertSame(500, $addon->metadata['increments']['extra_photos'] ?? null); + $this->assertNull($addon->metadata['price_eur'] ?? null); } public function test_paypal_checkout_creates_pending_addon_record(): void @@ -149,5 +152,114 @@ class EventAddonCheckoutTest extends TenantTestCase $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); $this->assertSame(1000, $addon->extra_photos); + $this->assertSame('Extra photos (500)', $addon->metadata['label'] ?? null); + $this->assertSame(12.5, $addon->metadata['price_eur'] ?? null); + } + + public function test_ai_styling_checkout_persists_feature_entitlement_metadata(): void + { + Config::set('package-addons.provider', CheckoutSession::PROVIDER_PAYPAL); + Config::set('checkout.currency', 'EUR'); + Config::set('package-addons.ai_styling_unlock', [ + 'label' => 'AI Styling Add-on', + 'price' => 9.00, + 'increments' => [], + 'metadata' => [ + 'scope' => 'feature', + ], + ]); + + $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), + ]); + + $orders = Mockery::mock(PayPalOrderService::class); + $orders->shouldReceive('createSimpleOrder') + ->once() + ->andReturn([ + 'id' => 'ORDER-AI-1', + 'links' => [ + ['rel' => 'approve', 'href' => 'https://paypal.test/approve-ai'], + ], + ]); + $orders->shouldReceive('resolveApproveUrl') + ->once() + ->andReturn('https://paypal.test/approve-ai'); + $this->app->instance(PayPalOrderService::class, $orders); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ + 'addon_key' => 'ai_styling_unlock', + 'quantity' => 1, + 'accepted_terms' => true, + 'accepted_waiver' => true, + ]); + + $response->assertOk(); + $response->assertJsonPath('checkout_id', 'ORDER-AI-1'); + + $this->assertDatabaseHas('event_package_addons', [ + 'event_package_id' => $eventPackage->id, + 'addon_key' => 'ai_styling_unlock', + 'status' => 'pending', + 'amount' => 9.00, + 'currency' => 'EUR', + ]); + + $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); + $this->assertSame('AI Styling Add-on', $addon->metadata['label'] ?? null); + $this->assertSame('feature', $addon->metadata['scope'] ?? null); + $this->assertSame(['ai_styling'], $addon->metadata['entitlements']['features'] ?? null); + $this->assertEquals(9.0, $addon->metadata['price_eur'] ?? null); + $this->assertSame(0, $addon->extra_photos); + $this->assertSame(0, $addon->extra_guests); + $this->assertSame(0, $addon->extra_gallery_days); + } + + public function test_checkout_requires_both_legal_consents(): 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::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' => 1, + 'accepted_terms' => true, + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['accepted_waiver']); } } diff --git a/tests/Feature/Tenant/EventAddonControllerTest.php b/tests/Feature/Tenant/EventAddonControllerTest.php index a1332a8d..bd0e4b4b 100644 --- a/tests/Feature/Tenant/EventAddonControllerTest.php +++ b/tests/Feature/Tenant/EventAddonControllerTest.php @@ -5,11 +5,10 @@ namespace Tests\Feature\Tenant; use App\Models\Event; use App\Models\EventPackage; use App\Models\Package; -use Carbon\Carbon; class EventAddonControllerTest extends TenantTestCase { - public function test_tenant_admin_can_apply_addons(): void + public function test_apply_endpoint_is_not_available_for_tenant_admins(): void { $package = Package::factory()->endcustomer()->create([ 'max_photos' => 100, @@ -28,11 +27,9 @@ class EventAddonControllerTest extends TenantTestCase 'purchased_at' => now()->subDay(), 'used_photos' => 10, 'used_guests' => 5, - 'gallery_expires_at' => Carbon::now()->addDays(7), + 'gallery_expires_at' => 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, @@ -40,39 +37,12 @@ class EventAddonControllerTest extends TenantTestCase 'reason' => 'Manual boost for event', ]); - $response->assertOk(); - $response->assertJsonPath('data.limits.photos.limit', 150); - $response->assertJsonPath('data.limits.guests.limit', 75); + $response->assertNotFound(); $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'); + $this->assertSame(0, (int) $eventPackage->extra_photos); + $this->assertSame(0, (int) $eventPackage->extra_guests); + $this->assertSame(0, (int) $eventPackage->extra_gallery_days); } } diff --git a/tests/Feature/Tenant/PackageCatalogAvailabilityTest.php b/tests/Feature/Tenant/PackageCatalogAvailabilityTest.php new file mode 100644 index 00000000..42d49cb3 --- /dev/null +++ b/tests/Feature/Tenant/PackageCatalogAvailabilityTest.php @@ -0,0 +1,50 @@ +create([ + 'type' => 'reseller', + 'price' => 99, + 'lemonsqueezy_variant_id' => null, + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/packages?type=reseller'); + + $response->assertOk(); + $response->assertJsonPath('data.0.checkout_provider', 'paypal'); + $response->assertJsonPath('data.0.can_checkout', true); + } + + public function test_lemonsqueezy_catalog_requires_variant_for_checkout(): void + { + Config::set('checkout.default_provider', 'lemonsqueezy'); + + Package::factory()->create([ + 'type' => 'reseller', + 'price' => 99, + 'lemonsqueezy_variant_id' => null, + ]); + + Package::factory()->create([ + 'type' => 'reseller', + 'price' => 199, + 'lemonsqueezy_variant_id' => 'pri_reseller_2', + ]); + + $response = $this->authenticatedRequest('GET', '/api/v1/tenant/packages?type=reseller'); + + $response->assertOk(); + $response->assertJsonPath('data.0.checkout_provider', 'lemonsqueezy'); + $response->assertJsonPath('data.0.can_checkout', false); + $response->assertJsonPath('data.1.can_checkout', true); + } +} diff --git a/tests/Unit/Services/EventAddonCatalogTest.php b/tests/Unit/Services/EventAddonCatalogTest.php index c8d511d7..00e37917 100644 --- a/tests/Unit/Services/EventAddonCatalogTest.php +++ b/tests/Unit/Services/EventAddonCatalogTest.php @@ -31,6 +31,10 @@ class EventAddonCatalogTest extends TestCase 'sort' => 1, 'metadata' => [ 'price_eur' => 12, + 'scope' => 'feature', + 'entitlements' => [ + 'features' => ['ai_styling'], + ], ], ]); @@ -44,5 +48,7 @@ class EventAddonCatalogTest extends TestCase $this->assertSame(200, $addon['increments']['extra_photos']); $this->assertSame(12.0, $addon['price']); $this->assertSame('EUR', $addon['currency']); + $this->assertSame('feature', $addon['metadata']['scope'] ?? null); + $this->assertSame(['ai_styling'], $addon['metadata']['entitlements']['features'] ?? null); } } diff --git a/tests/Unit/Services/EventFeatureEntitlementServiceTest.php b/tests/Unit/Services/EventFeatureEntitlementServiceTest.php new file mode 100644 index 00000000..67554e73 --- /dev/null +++ b/tests/Unit/Services/EventFeatureEntitlementServiceTest.php @@ -0,0 +1,108 @@ +create(); + $package = Package::factory()->endcustomer()->create([ + 'features' => ['basic_uploads', 'ai_styling'], + ]); + + EventPackage::query()->create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'gallery_expires_at' => now()->addDays(30), + ]); + + $result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['ai_styling_unlock']); + + $this->assertTrue($result['allowed']); + $this->assertSame('package', $result['granted_by']); + } + + public function test_it_grants_feature_via_completed_addon_key(): void + { + $service = app(EventFeatureEntitlementService::class); + $event = Event::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'features' => ['basic_uploads'], + ]); + + $eventPackage = EventPackage::query()->create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'gallery_expires_at' => now()->addDays(30), + ]); + + EventPackageAddon::query()->create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $event->tenant_id, + 'addon_key' => 'custom_entitlement', + 'quantity' => 1, + 'status' => 'completed', + 'purchased_at' => now(), + ]); + + $result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['custom_entitlement']); + + $this->assertTrue($result['allowed']); + $this->assertSame('addon', $result['granted_by']); + } + + public function test_it_denies_feature_when_addon_is_expired(): void + { + $service = app(EventFeatureEntitlementService::class); + $event = Event::factory()->create(); + $package = Package::factory()->endcustomer()->create([ + 'features' => ['basic_uploads'], + ]); + + $eventPackage = EventPackage::query()->create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'gallery_expires_at' => now()->addDays(30), + ]); + + EventPackageAddon::query()->create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $event->tenant_id, + 'addon_key' => 'custom_entitlement', + 'quantity' => 1, + 'status' => 'completed', + 'purchased_at' => now()->subDays(2), + 'metadata' => [ + 'entitlements' => [ + 'expires_at' => now()->subDay()->toIso8601String(), + 'features' => ['ai_styling'], + ], + ], + ]); + + $result = $service->resolveForEvent($event->fresh(), 'ai_styling', ['custom_entitlement']); + + $this->assertFalse($result['allowed']); + $this->assertNull($result['granted_by']); + } +}