feat(addons): finalize event addon catalog and ai styling upgrade flow
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Tenant;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventAddonRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, mixed>
|
||||
*/
|
||||
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<string, int>
|
||||
*/
|
||||
|
||||
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $addon
|
||||
*/
|
||||
protected function resolveAddonLabel(string $addonKey, array $addon): string
|
||||
{
|
||||
$label = trim((string) ($addon['label'] ?? ''));
|
||||
|
||||
return $label !== '' ? $label : $addonKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $addon
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function resolveAddonMetadata(array $addon): array
|
||||
{
|
||||
$metadata = $addon['metadata'] ?? [];
|
||||
|
||||
return is_array($metadata) ? $metadata : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $addonMetadata
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
174
app/Services/Addons/EventFeatureEntitlementService.php
Normal file
174
app/Services/Addons/EventFeatureEntitlementService.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventFeatureEntitlementService
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $addonKeys
|
||||
* @return array{
|
||||
* allowed: bool,
|
||||
* granted_by: 'package'|'addon'|null,
|
||||
* required_feature: string,
|
||||
* addon_keys: array<int, string>
|
||||
* }
|
||||
*/
|
||||
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<int, string> $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<int, string>
|
||||
*/
|
||||
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))
|
||||
)));
|
||||
}
|
||||
}
|
||||
@@ -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<int, string> $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<int, string>
|
||||
*/
|
||||
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))
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
29
composer.lock
generated
29
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "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"
|
||||
}
|
||||
|
||||
@@ -38,5 +38,11 @@ return [
|
||||
'price' => (float) env('ADDON_AI_STYLING_PRICE', 9.00),
|
||||
'currency' => 'EUR',
|
||||
'increments' => [],
|
||||
'metadata' => [
|
||||
'scope' => 'feature',
|
||||
'entitlements' => [
|
||||
'features' => ['ai_styling'],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('package_addons')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = Carbon::now();
|
||||
$existing = DB::table('package_addons')
|
||||
->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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
};
|
||||
@@ -142,6 +142,7 @@ class PackageAddonSeeder extends Seeder
|
||||
'sort' => 42,
|
||||
'metadata' => [
|
||||
'price_eur' => 9,
|
||||
'scope' => 'feature',
|
||||
'entitlements' => [
|
||||
'features' => ['ai_styling'],
|
||||
],
|
||||
|
||||
@@ -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==
|
||||
@@ -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
|
||||
@@ -1990,7 +1990,14 @@ export async function getEventAiEditSummary(slug: string): Promise<AiEditUsageSu
|
||||
|
||||
export async function createEventAddonCheckout(
|
||||
eventSlug: string,
|
||||
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
|
||||
params: {
|
||||
addon_key: string;
|
||||
quantity?: number;
|
||||
success_url?: string;
|
||||
cancel_url?: string;
|
||||
accepted_terms?: boolean;
|
||||
accepted_waiver?: boolean;
|
||||
}
|
||||
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
|
||||
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<string, boolean> | null;
|
||||
|
||||
@@ -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<TenantAddonHistoryEntry[]>([]);
|
||||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [addonsMeta, setAddonsMeta] = React.useState<PaginationMeta>(() =>
|
||||
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<EventAddonSummary[]>(() => {
|
||||
const rows = scopeEvent?.addons;
|
||||
@@ -689,6 +697,25 @@ export default function MobileBillingPage() {
|
||||
{t('billing.sections.currentEvent.noAddons', 'No add-ons purchased for this event.')}
|
||||
</Text>
|
||||
)}
|
||||
{scopeEvent?.slug ? (
|
||||
hasSellableAddons ? (
|
||||
<YStack gap="$2" marginTop="$1">
|
||||
<CTAButton
|
||||
label={t('billing.sections.currentEvent.openAddonShop', 'Buy add-ons for this event')}
|
||||
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
|
||||
/>
|
||||
<CTAButton
|
||||
label={t('billing.sections.currentEvent.openAddonUpsell', 'Open control-room upgrades')}
|
||||
tone="ghost"
|
||||
onPress={() => navigate(eventControlRoomPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/control-room`))}
|
||||
/>
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('billing.sections.currentEvent.noAddonCatalog', 'No add-ons are currently available for purchase.')}
|
||||
</Text>
|
||||
)
|
||||
) : null}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)}
|
||||
@@ -862,9 +889,17 @@ export default function MobileBillingPage() {
|
||||
{t('common.loading', 'Lädt...')}
|
||||
</Text>
|
||||
) : addons.length === 0 ? (
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
||||
</Text>
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$sm" color={text}>
|
||||
{t('billing.sections.addOns.empty', 'Keine Add-ons gebucht.')}
|
||||
</Text>
|
||||
{scopeEvent?.slug && hasSellableAddons ? (
|
||||
<CTAButton
|
||||
label={t('billing.sections.addOns.openShop', 'Buy add-ons now')}
|
||||
onPress={() => navigate(eventRecapPath ?? adminPath(`/mobile/events/${scopeEvent.slug}/recap`))}
|
||||
/>
|
||||
) : null}
|
||||
</YStack>
|
||||
) : (
|
||||
<YStack gap="$1.5">
|
||||
{addons.map((addon) => (
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : aiStylingAddon ? (
|
||||
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('controlRoom.aiUpsell.title', 'Unlock AI Magic Edit')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t(
|
||||
'controlRoom.aiUpsell.body',
|
||||
'Buy the AI Styling add-on for this event without upgrading your full package.'
|
||||
)}
|
||||
</Text>
|
||||
<CTAButton
|
||||
label={aiStylingAddonCta}
|
||||
onPress={() => startAddonCheckout('ai')}
|
||||
loading={busyScope === 'ai'}
|
||||
disabled={consentBusy}
|
||||
/>
|
||||
</MobileCard>
|
||||
) : null
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<MobileShell
|
||||
@@ -397,16 +397,19 @@ export default function MobileEventRecapPage() {
|
||||
</Text>
|
||||
|
||||
<YStack gap="$2">
|
||||
{addons
|
||||
.filter((a) => a.key === 'gallery_extension')
|
||||
.map((addon) => (
|
||||
<CTAButton
|
||||
key={addon.key}
|
||||
label={t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
|
||||
onPress={() => handleCheckout(addon.key)}
|
||||
loading={busyScope === addon.key}
|
||||
/>
|
||||
))}
|
||||
{galleryExtensionAddons.map((addon) => (
|
||||
<CTAButton
|
||||
key={addon.key}
|
||||
label={addon.label || t('events.recap.buyExtension', 'Galerie um 30 Tage verlängern')}
|
||||
onPress={() => handleCheckout(addon.key)}
|
||||
loading={busyScope === addon.key}
|
||||
/>
|
||||
))}
|
||||
{galleryExtensionAddons.length === 0 ? (
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t('events.recap.noAddonAvailable', 'Aktuell sind keine Galerie-Add-ons verfügbar.')}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
</YStack>
|
||||
|
||||
@@ -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({
|
||||
<YStack width={labelWidth} />
|
||||
{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();
|
||||
|
||||
@@ -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(<MobileBillingPage />);
|
||||
|
||||
expect(await screen.findByText('Buy add-ons for this event')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(<MobileEventControlRoomPage />);
|
||||
|
||||
expect(await screen.findByText('Unlock AI Magic Edit')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Unlock AI Magic Edit/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }) => <div>{children}</div>,
|
||||
CTAButton: ({ label }: { label: string }) => <button type="button">{label}</button>,
|
||||
CTAButton: ({ label, onPress }: { label: string; onPress?: () => void }) => (
|
||||
<button type="button" onClick={onPress}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
SkeletonCard: () => <div>Loading...</div>,
|
||||
}));
|
||||
@@ -101,7 +116,13 @@ vi.mock('../components/FormControls', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../components/LegalConsentSheet', () => ({
|
||||
LegalConsentSheet: () => <div />,
|
||||
LegalConsentSheet: ({
|
||||
open,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
onConfirm: (consents: { acceptedTerms: boolean; acceptedWaiver: boolean }) => void;
|
||||
}) => (open ? <button type="button" onClick={() => onConfirm({ acceptedTerms: true, acceptedWaiver: true })}>Confirm legal</button> : 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(<MobileEventRecapPage />);
|
||||
|
||||
@@ -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(<MobileEventRecapPage />);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
79
tests/Feature/Tenant/EventAddonCatalogControllerTest.php
Normal file
79
tests/Feature/Tenant/EventAddonCatalogControllerTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class EventAddonCatalogControllerTest extends TenantTestCase
|
||||
{
|
||||
public function test_paypal_catalog_only_returns_addons_with_paypal_price(): void
|
||||
{
|
||||
Config::set('package-addons.provider', 'paypal');
|
||||
|
||||
PackageAddon::create([
|
||||
'key' => '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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
50
tests/Feature/Tenant/PackageCatalogAvailabilityTest.php
Normal file
50
tests/Feature/Tenant/PackageCatalogAvailabilityTest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Tenant;
|
||||
|
||||
use App\Models\Package;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
|
||||
class PackageCatalogAvailabilityTest extends TenantTestCase
|
||||
{
|
||||
public function test_paypal_catalog_marks_paid_reseller_packages_as_checkout_ready(): void
|
||||
{
|
||||
Config::set('checkout.default_provider', 'paypal');
|
||||
|
||||
Package::factory()->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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
108
tests/Unit/Services/EventFeatureEntitlementServiceTest.php
Normal file
108
tests/Unit/Services/EventFeatureEntitlementServiceTest.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Services;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Package;
|
||||
use App\Services\Addons\EventFeatureEntitlementService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventFeatureEntitlementServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_grants_feature_via_package(): void
|
||||
{
|
||||
$service = app(EventFeatureEntitlementService::class);
|
||||
$event = Event::factory()->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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user