implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history

This commit is contained in:
Codex Agent
2025-11-21 11:25:45 +01:00
parent 07fe049b8a
commit 7a8d22a238
58 changed files with 3339 additions and 60 deletions

View File

@@ -0,0 +1,147 @@
<?php
namespace App\Filament\Resources;
use App\Filament\Resources\PackageAddonResource\Pages;
use App\Jobs\SyncPackageAddonToPaddle;
use App\Models\PackageAddon;
use Filament\Actions;
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\Schema;
use Filament\Tables;
use Filament\Tables\Columns\BadgeColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class PackageAddonResource extends Resource
{
protected static ?string $model = PackageAddon::class;
protected static \BackedEnum|string|null $navigationIcon = 'heroicon-o-plus-circle';
protected static ?int $navigationSort = 6;
public static function getNavigationGroup(): \BackedEnum|string|null
{
return __('admin.nav.platform_management');
}
public static function form(Schema $schema): Schema
{
return $schema->schema([
Section::make('Add-on Details')
->columns(2)
->schema([
TextInput::make('label')
->label('Label')
->required()
->maxLength(255),
TextInput::make('key')
->label('Schlüssel')
->required()
->unique(ignoreRecord: true)
->maxLength(191),
TextInput::make('price_id')
->label('Paddle Preis-ID')
->helperText('Paddle Billing Preis-ID für dieses Add-on')
->maxLength(191),
TextInput::make('sort')
->label('Sortierung')
->numeric()
->default(0),
Toggle::make('active')
->label('Aktiv')
->default(true),
]),
Section::make('Limits-Inkremente')
->columns(3)
->schema([
TextInput::make('extra_photos')
->label('Extra Fotos')
->numeric()
->minValue(0)
->default(0),
TextInput::make('extra_guests')
->label('Extra Gäste')
->numeric()
->minValue(0)
->default(0),
TextInput::make('extra_gallery_days')
->label('Galerie +Tage')
->numeric()
->minValue(0)
->default(0),
]),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('label')
->label('Label')
->searchable()
->sortable(),
TextColumn::make('key')
->label('Schlüssel')
->copyable()
->sortable(),
TextColumn::make('price_id')
->label('Paddle Preis-ID')
->toggleable()
->copyable(),
TextColumn::make('extra_photos')->label('Fotos +'),
TextColumn::make('extra_guests')->label('Gäste +'),
TextColumn::make('extra_gallery_days')->label('Galerietage +'),
BadgeColumn::make('active')
->label('Status')
->colors([
'success' => true,
'danger' => false,
])
->formatStateUsing(fn (bool $state) => $state ? 'Aktiv' : 'Inaktiv'),
TextColumn::make('sort')
->label('Sort')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
Tables\Filters\TernaryFilter::make('active')
->label('Aktiv'),
])
->actions([
Actions\Action::make('syncPaddle')
->label('Mit Paddle synchronisieren')
->icon('heroicon-o-cloud-arrow-up')
->action(function (PackageAddon $record) {
SyncPackageAddonToPaddle::dispatch($record->id);
Notification::make()
->success()
->title('Paddle-Sync gestartet')
->body('Das Add-on wird im Hintergrund mit Paddle abgeglichen.')
->send();
}),
Actions\EditAction::make(),
])
->bulkActions([
Actions\BulkActionGroup::make([
Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getPages(): array
{
return [
'index' => Pages\ListPackageAddons::route('/'),
'create' => Pages\CreatePackageAddon::route('/create'),
'edit' => Pages\EditPackageAddon::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PackageAddonResource\Pages;
use App\Filament\Resources\PackageAddonResource;
use Filament\Resources\Pages\CreateRecord;
class CreatePackageAddon extends CreateRecord
{
protected static string $resource = PackageAddonResource::class;
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\PackageAddonResource\Pages;
use App\Filament\Resources\PackageAddonResource;
use Filament\Resources\Pages\EditRecord;
class EditPackageAddon extends EditRecord
{
protected static string $resource = PackageAddonResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\PackageAddonResource\Pages;
use App\Filament\Resources\PackageAddonResource;
use Filament\Actions;
use Filament\Resources\Pages\ListRecords;
class ListPackageAddons extends ListRecords
{
protected static string $resource = PackageAddonResource::class;
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Services\Addons\EventAddonCatalog;
use Illuminate\Http\JsonResponse;
class EventAddonCatalogController extends Controller
{
public function __construct(private readonly EventAddonCatalog $catalog) {}
public function index(): JsonResponse
{
$addons = collect($this->catalog->all())
->filter(fn (array $addon) => ! empty($addon['price_id']))
->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key]))
->values()
->all();
return response()->json(['data' => $addons]);
}
}

View File

@@ -0,0 +1,114 @@
<?php
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
{
public function __construct(private readonly EventAddonCheckoutService $checkoutService) {}
public function checkout(EventAddonCheckoutRequest $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 ?: $event->eventPackages()
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
if ($eventPackage) {
$event->setRelation('eventPackage', $eventPackage);
}
$checkout = $this->checkoutService->createCheckout(
$request->attributes->get('tenant'),
$event,
$request->validated(),
);
return response()->json([
'checkout_url' => $checkout['checkout_url'] ?? null,
'checkout_id' => $checkout['id'] ?? null,
'expires_at' => $checkout['expires_at'] ?? null,
]);
}
public function apply(EventAddonRequest $request, Event $event): JsonResponse
{
$tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) {
return ApiError::response(
'event_not_found',
'Event not accessible',
__('Das Event konnte nicht gefunden werden.'),
404,
['event_slug' => $event->slug ?? null]
);
}
$eventPackage = $event->eventPackage;
if (! $eventPackage && method_exists($event, 'eventPackages')) {
$eventPackage = $event->eventPackages()
->with('package')
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->first();
}
if (! $eventPackage) {
return ApiError::response(
'event_package_missing',
'Event package missing',
__('Kein Paket ist diesem Event zugeordnet.'),
409,
['event_slug' => $event->slug ?? null]
);
}
$data = $request->validated();
$eventPackage->fill([
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($data['extra_photos'] ?? 0),
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($data['extra_guests'] ?? 0),
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($data['extend_gallery_days'] ?? 0),
]);
if (isset($data['extend_gallery_days'])) {
$base = $eventPackage->gallery_expires_at ?? Carbon::now();
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) $data['extend_gallery_days']);
}
$eventPackage->save();
$event->load([
'eventPackage.package',
'eventPackages.package',
]);
return response()->json([
'message' => __('Add-ons applied successfully.'),
'data' => new EventResource($event),
]);
}
}

View File

@@ -192,7 +192,7 @@ class EventController extends Controller
}); });
$tenant->refresh(); $tenant->refresh();
$event->load(['eventType', 'tenant', 'eventPackages.package']); $event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
return response()->json([ return response()->json([
'message' => 'Event created successfully', 'message' => 'Event created successfully',
@@ -222,7 +222,7 @@ class EventController extends Controller
'tasks', 'tasks',
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'), 'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
'eventPackages' => fn ($query) => $query 'eventPackages' => fn ($query) => $query
->with('package') ->with(['package', 'addons'])
->orderByDesc('purchased_at') ->orderByDesc('purchased_at')
->orderByDesc('created_at'), ->orderByDesc('created_at'),
]); ]);

View File

@@ -3,9 +3,11 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\EventPackageAddon;
use App\Services\Paddle\PaddleTransactionService; use App\Services\Paddle\PaddleTransactionService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
class TenantBillingController extends Controller class TenantBillingController extends Controller
@@ -60,4 +62,58 @@ class TenantBillingController extends Controller
'meta' => $result['meta'], 'meta' => $result['meta'],
]); ]);
} }
public function addons(Request $request): JsonResponse
{
$tenant = $request->attributes->get('tenant');
if (! $tenant) {
return response()->json([
'data' => [],
'message' => 'Tenant not found.',
], 404);
}
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
$page = max(1, (int) $request->query('page', 1));
$paginator = EventPackageAddon::query()
->where('tenant_id', $tenant->id)
->with(['event:id,name,slug'])
->orderByDesc('purchased_at')
->orderByDesc('created_at')
->paginate($perPage, ['*'], 'page', $page);
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
return [
'id' => $addon->id,
'addon_key' => $addon->addon_key,
'label' => $addon->metadata['label'] ?? null,
'quantity' => (int) ($addon->quantity ?? 1),
'status' => $addon->status,
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
'currency' => $addon->currency,
'extra_photos' => (int) $addon->extra_photos,
'extra_guests' => (int) $addon->extra_guests,
'extra_gallery_days' => (int) $addon->extra_gallery_days,
'purchased_at' => $addon->purchased_at?->toIso8601String(),
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
'event' => $addon->event ? [
'id' => $addon->event->id,
'slug' => $addon->event->slug,
'name' => $addon->event->name,
] : null,
];
})->values();
return response()->json([
'data' => $data,
'meta' => [
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'per_page' => $paginator->perPage(),
'total' => $paginator->total(),
],
]);
}
} }

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Services\Addons\EventAddonWebhookService;
use App\Services\Checkout\CheckoutWebhookService; use App\Services\Checkout\CheckoutWebhookService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -10,7 +11,10 @@ use Symfony\Component\HttpFoundation\Response;
class PaddleWebhookController extends Controller class PaddleWebhookController extends Controller
{ {
public function __construct(private readonly CheckoutWebhookService $webhooks) {} public function __construct(
private readonly CheckoutWebhookService $webhooks,
private readonly EventAddonWebhookService $addonWebhooks,
) {}
public function handle(Request $request): JsonResponse public function handle(Request $request): JsonResponse
{ {
@@ -31,6 +35,7 @@ class PaddleWebhookController extends Controller
if ($eventType) { if ($eventType) {
$handled = $this->webhooks->handlePaddleEvent($payload); $handled = $this->webhooks->handlePaddleEvent($payload);
$handled = $this->addonWebhooks->handle($payload) || $handled;
} }
Log::info('Paddle webhook processed', [ Log::info('Paddle webhook processed', [

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Tenant;
use App\Services\Addons\EventAddonCatalog;
use Illuminate\Foundation\Http\FormRequest;
class EventAddonCheckoutRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var EventAddonCatalog $catalog */
$catalog = app(EventAddonCatalog::class);
$keys = array_keys($catalog->all());
$inRule = $keys === [] ? 'string' : 'in:'.implode(',', $keys);
return [
'addon_key' => ['required', 'string', $inRule],
'quantity' => ['nullable', 'integer', 'min:1', 'max:50'],
'success_url' => ['nullable', 'url'],
'cancel_url' => ['nullable', 'url'],
];
}
}

View File

@@ -0,0 +1,40 @@
<?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.'),
]);
}
}
}

View File

@@ -6,6 +6,7 @@ use App\Services\Packages\PackageLimitEvaluator;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
use Illuminate\Support\Arr;
class EventResource extends JsonResource class EventResource extends JsonResource
{ {
@@ -76,6 +77,34 @@ class EventResource extends JsonResource
'limits' => $eventPackage && $limitEvaluator 'limits' => $eventPackage && $limitEvaluator
? $limitEvaluator->summarizeEventPackage($eventPackage) ? $limitEvaluator->summarizeEventPackage($eventPackage)
: null, : null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
]; ];
} }
protected function formatAddons(?\App\Models\EventPackage $eventPackage): array
{
if (! $eventPackage) {
return [];
}
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()->latest()->take(10)->get();
return $addons->map(function ($addon) {
return [
'id' => $addon->id,
'key' => $addon->addon_key,
'label' => $addon->metadata['label'] ?? null,
'status' => $addon->status,
'price_id' => $addon->price_id,
'transaction_id' => $addon->transaction_id,
'extra_photos' => (int) $addon->extra_photos,
'extra_guests' => (int) $addon->extra_guests,
'extra_gallery_days' => (int) $addon->extra_gallery_days,
'purchased_at' => $addon->purchased_at?->toIso8601String(),
'metadata' => Arr::only($addon->metadata ?? [], ['price_eur']),
];
})->all();
}
} }

View File

@@ -0,0 +1,167 @@
<?php
namespace App\Jobs;
use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException;
use App\Services\Paddle\PaddleAddonCatalogService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Throwable;
class SyncPackageAddonToPaddle implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
/**
* @param array{dry_run?: bool, product?: array<string, mixed>, price?: array<string, mixed>} $options
*/
public function __construct(private readonly int $addonId, private readonly array $options = []) {}
public function handle(PaddleAddonCatalogService $catalog): void
{
$addon = PackageAddon::query()->find($this->addonId);
if (! $addon) {
return;
}
$dryRun = (bool) ($this->options['dry_run'] ?? false);
$productOverrides = Arr::get($this->options, 'product', []);
$priceOverrides = Arr::get($this->options, 'price', []);
if ($dryRun) {
$this->storeDryRunSnapshot($catalog, $addon, $productOverrides, $priceOverrides);
return;
}
// Mark syncing (metadata)
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'syncing',
'paddle_synced_at' => now()->toIso8601String(),
]),
])->save();
try {
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$productResponse = $addon->metadata['paddle_product_id'] ?? null
? $catalog->updateProduct($addon->metadata['paddle_product_id'], $addon, $payloadOverrides['product'])
: $catalog->createProduct($addon, $payloadOverrides['product']);
$productId = (string) ($productResponse['id'] ?? $addon->metadata['paddle_product_id'] ?? null);
if (! $productId) {
throw new PaddleException('Paddle product ID missing after addon sync.');
}
$priceResponse = $addon->price_id
? $catalog->updatePrice($addon->price_id, $addon, array_merge($payloadOverrides['price'], ['product_id' => $productId]))
: $catalog->createPrice($addon, $productId, $payloadOverrides['price']);
$priceId = (string) ($priceResponse['id'] ?? $addon->price_id);
if (! $priceId) {
throw new PaddleException('Paddle price ID missing after addon sync.');
}
$addon->forceFill([
'price_id' => $priceId,
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'synced',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_product_id' => $productId,
'paddle_snapshot' => [
'product' => $productResponse,
'price' => $priceResponse,
'payload' => $payloadOverrides,
],
]),
])->save();
} catch (Throwable $exception) {
Log::error('Paddle addon sync failed', [
'addon_id' => $addon->id,
'message' => $exception->getMessage(),
'exception' => $exception,
]);
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'failed',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_error' => [
'message' => $exception->getMessage(),
'class' => $exception::class,
],
]),
])->save();
throw $exception;
}
}
/**
* @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides
* @return array{product: array<string, mixed>, price: array<string, mixed>}
*/
protected function buildPayloadOverrides(PackageAddon $addon, array $productOverrides, array $priceOverrides): array
{
// Reuse Package model payload builder shape via duck typing
$baseProduct = [
'name' => $addon->label,
'description' => sprintf('Addon %s', $addon->key),
'type' => 'standard',
'custom_data' => [
'addon_key' => $addon->key,
'increments' => $addon->increments,
],
];
$basePrice = [
'description' => $addon->label,
'custom_data' => [
'addon_key' => $addon->key,
],
];
return [
'product' => array_merge($baseProduct, $productOverrides),
'price' => array_merge($basePrice, $priceOverrides),
];
}
/**
* @param array<string, mixed> $productOverrides
* @param array<string, mixed> $priceOverrides
*/
protected function storeDryRunSnapshot(PaddleCatalogService $catalog, PackageAddon $addon, array $productOverrides, array $priceOverrides): void
{
$payloadOverrides = $this->buildPayloadOverrides($addon, $productOverrides, $priceOverrides);
$addon->forceFill([
'metadata' => array_merge($addon->metadata ?? [], [
'paddle_sync_status' => 'dry-run',
'paddle_synced_at' => now()->toIso8601String(),
'paddle_snapshot' => [
'dry_run' => true,
'payload' => $payloadOverrides,
],
]),
])->save();
Log::info('Paddle addon dry-run snapshot generated', [
'addon_id' => $addon->id,
]);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EventPackage extends Model class EventPackage extends Model
{ {
@@ -20,6 +21,10 @@ class EventPackage extends Model
'used_photos', 'used_photos',
'used_guests', 'used_guests',
'gallery_expires_at', 'gallery_expires_at',
'limits_snapshot',
'extra_photos',
'extra_guests',
'extra_gallery_days',
]; ];
protected $casts = [ protected $casts = [
@@ -30,6 +35,10 @@ class EventPackage extends Model
'gallery_expired_notified_at' => 'datetime', 'gallery_expired_notified_at' => 'datetime',
'used_photos' => 'integer', 'used_photos' => 'integer',
'used_guests' => 'integer', 'used_guests' => 'integer',
'extra_photos' => 'integer',
'extra_guests' => 'integer',
'extra_gallery_days' => 'integer',
'limits_snapshot' => 'array',
]; ];
public function event(): BelongsTo public function event(): BelongsTo
@@ -42,6 +51,11 @@ class EventPackage extends Model
return $this->belongsTo(Package::class)->withTrashed(); return $this->belongsTo(Package::class)->withTrashed();
} }
public function addons(): HasMany
{
return $this->hasMany(EventPackageAddon::class);
}
public function isActive(): bool public function isActive(): bool
{ {
return $this->gallery_expires_at && $this->gallery_expires_at->isFuture(); return $this->gallery_expires_at && $this->gallery_expires_at->isFuture();
@@ -53,7 +67,11 @@ class EventPackage extends Model
return false; return false;
} }
$maxPhotos = $this->package->max_photos ?? 0; $maxPhotos = $this->effectiveLimits()['max_photos'];
if ($maxPhotos === null) {
return true;
}
return $this->used_photos < $maxPhotos; return $this->used_photos < $maxPhotos;
} }
@@ -64,23 +82,84 @@ class EventPackage extends Model
return false; return false;
} }
$maxGuests = $this->package->max_guests ?? 0; $maxGuests = $this->effectiveLimits()['max_guests'];
if ($maxGuests === null) {
return true;
}
return $this->used_guests < $maxGuests; return $this->used_guests < $maxGuests;
} }
public function getRemainingPhotosAttribute(): int public function getRemainingPhotosAttribute(): int
{ {
$max = $this->package->max_photos ?? 0; $limit = $this->effectiveLimits()['max_photos'] ?? 0;
return max(0, $max - $this->used_photos); return max(0, (int) $limit - $this->used_photos);
} }
public function getRemainingGuestsAttribute(): int public function getRemainingGuestsAttribute(): int
{ {
$max = $this->package->max_guests ?? 0; $limit = $this->effectiveLimits()['max_guests'] ?? 0;
return max(0, $max - $this->used_guests); return max(0, (int) $limit - $this->used_guests);
}
/**
* @return array{max_photos: ?int, max_guests: ?int, gallery_days: ?int, max_tasks: ?int, max_events_per_year: ?int}
*/
public function effectiveLimits(): array
{
$snapshot = is_array($this->limits_snapshot) ? $this->limits_snapshot : [];
$base = [
'max_photos' => array_key_exists('max_photos', $snapshot)
? $snapshot['max_photos']
: ($this->package->max_photos ?? null),
'max_guests' => array_key_exists('max_guests', $snapshot)
? $snapshot['max_guests']
: ($this->package->max_guests ?? null),
'gallery_days' => array_key_exists('gallery_days', $snapshot)
? $snapshot['gallery_days']
: ($this->package->gallery_days ?? null),
'max_tasks' => array_key_exists('max_tasks', $snapshot)
? $snapshot['max_tasks']
: ($this->package->max_tasks ?? null),
'max_events_per_year' => array_key_exists('max_events_per_year', $snapshot)
? $snapshot['max_events_per_year']
: ($this->package->max_events_per_year ?? null),
];
$applyExtra = static function (?int $limit, int $extra): ?int {
if ($limit === null) {
return null;
}
$safeExtra = max(0, $extra);
return max(0, $limit + $safeExtra);
};
$maxPhotos = $applyExtra($this->normalizeLimit($base['max_photos']), (int) ($this->extra_photos ?? 0));
$maxGuests = $applyExtra($this->normalizeLimit($base['max_guests']), (int) ($this->extra_guests ?? 0));
return [
'max_photos' => $maxPhotos,
'max_guests' => $maxGuests,
'gallery_days' => $this->normalizeLimit($base['gallery_days']),
'max_tasks' => $this->normalizeLimit($base['max_tasks']),
'max_events_per_year' => $this->normalizeLimit($base['max_events_per_year']),
];
}
public function effectivePhotoLimit(): ?int
{
return $this->effectiveLimits()['max_photos'];
}
public function effectiveGuestLimit(): ?int
{
return $this->effectiveLimits()['max_guests'];
} }
protected static function boot() protected static function boot()
@@ -95,6 +174,31 @@ class EventPackage extends Model
$days = $eventPackage->package->gallery_days ?? 30; $days = $eventPackage->package->gallery_days ?? 30;
$eventPackage->gallery_expires_at = now()->addDays($days); $eventPackage->gallery_expires_at = now()->addDays($days);
} }
if (! $eventPackage->limits_snapshot) {
$package = $eventPackage->relationLoaded('package')
? $eventPackage->package
: Package::query()->find($eventPackage->package_id);
if ($package) {
$eventPackage->limits_snapshot = array_filter([
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,
'gallery_days' => $package->gallery_days,
'max_tasks' => $package->max_tasks,
'max_events_per_year' => $package->max_events_per_year,
], static fn ($value) => $value !== null);
}
}
}); });
} }
private function normalizeLimit($value): ?int
{
if ($value === null) {
return null;
}
return is_numeric($value) ? (int) $value : null;
}
} }

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EventPackageAddon extends Model
{
use HasFactory;
protected $fillable = [
'event_package_id',
'event_id',
'tenant_id',
'addon_key',
'quantity',
'extra_photos',
'extra_guests',
'extra_gallery_days',
'price_id',
'checkout_id',
'transaction_id',
'status',
'amount',
'currency',
'metadata',
'receipt_payload',
'error',
'purchased_at',
];
protected $casts = [
'metadata' => 'array',
'receipt_payload' => 'array',
'purchased_at' => 'datetime',
];
protected function increments(): Attribute
{
return Attribute::make(
get: fn () => [
'extra_photos' => (int) ($this->extra_photos ?? 0),
'extra_guests' => (int) ($this->extra_guests ?? 0),
'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0),
],
);
}
public function eventPackage(): BelongsTo
{
return $this->belongsTo(EventPackage::class);
}
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class PackageAddon extends Model
{
use HasFactory;
protected $fillable = [
'key',
'label',
'price_id',
'extra_photos',
'extra_guests',
'extra_gallery_days',
'active',
'sort',
'metadata',
];
protected $casts = [
'active' => 'boolean',
'metadata' => 'array',
'extra_photos' => 'integer',
'extra_guests' => 'integer',
'extra_gallery_days' => 'integer',
'sort' => 'integer',
];
protected function increments(): Attribute
{
return Attribute::make(
get: fn () => [
'extra_photos' => (int) ($this->extra_photos ?? 0),
'extra_guests' => (int) ($this->extra_guests ?? 0),
'extra_gallery_days' => (int) ($this->extra_gallery_days ?? 0),
],
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Notifications\Addons;
use App\Models\EventPackageAddon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Notifications\Notification;
class AddonPurchaseReceipt extends Notification implements ShouldQueue
{
use Queueable;
public function __construct(private readonly EventPackageAddon $addon) {}
public function via(object $notifiable): array
{
return ['mail'];
}
public function toMail(object $notifiable): MailMessage
{
$event = $this->addon->event;
$tenant = $event?->tenant;
$label = $this->addon->metadata['label'] ?? $this->addon->addon_key;
$amount = $this->addon->amount ? number_format((float) $this->addon->amount, 2) : null;
$currency = $this->addon->currency ?? 'EUR';
$url = url('/tenant/events/'.($event?->slug ?? ''));
return (new MailMessage)
->subject(__('emails.addons.receipt.subject', ['addon' => $label]))
->greeting(__('emails.addons.receipt.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]))
->line(__('emails.addons.receipt.body', [
'addon' => $label,
'event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'),
'amount' => $amount ? $amount.' '.$currency : __('emails.addons.receipt.unknown_amount'),
]))
->line(__('emails.addons.receipt.summary', [
'photos' => $this->addon->extra_photos,
'guests' => $this->addon->extra_guests,
'days' => $this->addon->extra_gallery_days,
]))
->action(__('emails.addons.receipt.action'), $url)
->line(__('emails.package_limits.footer'));
}
}

View File

@@ -43,6 +43,8 @@ class EventPackageGuestLimitNotification extends Notification implements ShouldQ
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
'limit' => $this->limit, 'limit' => $this->limit,
])) ]))
->line(__('emails.package_limits.guest_limit.cta_addon'))
->action(__('emails.package_limits.guest_limit.addon_action'), $url)
->action(__('emails.package_limits.guest_limit.action'), $url) ->action(__('emails.package_limits.guest_limit.action'), $url)
->line(__('emails.package_limits.footer')); ->line(__('emails.package_limits.footer'));
} }

View File

@@ -43,6 +43,8 @@ class EventPackagePhotoLimitNotification extends Notification implements ShouldQ
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'), 'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
'limit' => $this->limit, 'limit' => $this->limit,
])) ]))
->line(__('emails.package_limits.photo_limit.cta_addon'))
->action(__('emails.package_limits.photo_limit.addon_action'), $url)
->action(__('emails.package_limits.photo_limit.action'), $url) ->action(__('emails.package_limits.photo_limit.action'), $url)
->line(__('emails.package_limits.footer')); ->line(__('emails.package_limits.footer'));
} }

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Addons;
use App\Models\PackageAddon;
use Illuminate\Support\Arr;
class EventAddonCatalog
{
/**
* @return array<string, mixed>
*/
public function all(): array
{
$dbAddons = PackageAddon::query()
->where('active', true)
->orderBy('sort')
->get()
->mapWithKeys(function (PackageAddon $addon) {
return [$addon->key => [
'label' => $addon->label,
'price_id' => $addon->price_id,
'increments' => $addon->increments,
]];
})
->all();
// Fallback to config and merge (DB wins)
$configAddons = config('package-addons', []);
return array_merge($configAddons, $dbAddons);
}
/**
* @return array<string, mixed>|null
*/
public function find(string $key): ?array
{
return $this->all()[$key] ?? null;
}
public function resolvePriceId(string $key): ?string
{
$addon = $this->find($key);
return $addon['price_id'] ?? null;
}
/**
* @return array<string, int>
*/
public function resolveIncrements(string $key): array
{
$addon = $this->find($key) ?? [];
$increments = Arr::get($addon, 'increments', []);
return collect($increments)
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
->filter(fn ($value) => $value > 0)
->all();
}
}

View File

@@ -0,0 +1,112 @@
<?php
namespace App\Services\Addons;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\Tenant;
use App\Services\Paddle\PaddleClient;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class EventAddonCheckoutService
{
public function __construct(
private readonly EventAddonCatalog $catalog,
private readonly PaddleClient $paddle,
) {}
/**
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
*/
public function createCheckout(Tenant $tenant, Event $event, array $payload): array
{
$addonKey = $payload['addon_key'] ?? null;
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
if (! $addonKey || ! $this->catalog->find($addonKey)) {
throw ValidationException::withMessages([
'addon_key' => __('Unbekanntes Add-on.'),
]);
}
$priceId = $this->catalog->resolvePriceId($addonKey);
if (! $priceId) {
throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'),
]);
}
$event->loadMissing('eventPackage');
if (! $event->eventPackage) {
throw ValidationException::withMessages([
'event' => __('Kein Paket für dieses Event hinterlegt.'),
]);
}
$addonIntent = (string) Str::uuid();
$increments = $this->catalog->resolveIncrements($addonKey);
$metadata = [
'tenant_id' => (string) $tenant->id,
'event_id' => (string) $event->id,
'event_package_id' => (string) $event->eventPackage->id,
'addon_key' => $addonKey,
'addon_intent' => $addonIntent,
'quantity' => $quantity,
];
$requestPayload = array_filter([
'customer_id' => $tenant->paddle_customer_id,
'items' => [
[
'price_id' => $priceId,
'quantity' => $quantity,
],
],
'metadata' => $metadata,
'success_url' => $payload['success_url'] ?? null,
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->paddle->post('/checkout/links', $requestPayload);
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
if (! $checkoutUrl) {
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
}
EventPackageAddon::create([
'event_package_id' => $event->eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $tenant->id,
'addon_key' => $addonKey,
'quantity' => $quantity,
'price_id' => $priceId,
'checkout_id' => $checkoutId,
'transaction_id' => null,
'status' => 'pending',
'metadata' => array_merge($metadata, [
'increments' => $increments,
'provider_payload' => $response,
]),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
]);
return [
'checkout_url' => $checkoutUrl,
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
'id' => $checkoutId,
];
}
}

View File

@@ -0,0 +1,125 @@
<?php
namespace App\Services\Addons;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Notifications\Addons\AddonPurchaseReceipt;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Notification;
class EventAddonWebhookService
{
public function __construct(private readonly EventAddonCatalog $catalog) {}
public function handle(array $payload): bool
{
$eventType = $payload['event_type'] ?? null;
$data = $payload['data'] ?? [];
if ($eventType !== 'transaction.completed') {
return false;
}
$metadata = $this->extractMetadata($data);
$intentId = $metadata['addon_intent'] ?? null;
$addonKey = $metadata['addon_key'] ?? null;
if (! $intentId || ! $addonKey) {
return false;
}
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
$checkoutId = $data['checkout_id'] ?? null;
$addon = EventPackageAddon::query()
->where('addon_key', $addonKey)
->where(function ($query) use ($intentId, $checkoutId, $transactionId) {
$query->where('metadata->addon_intent', $intentId)
->orWhere('checkout_id', $checkoutId)
->orWhere('transaction_id', $transactionId);
})
->latest('id')
->first();
if (! $addon) {
Log::info('[AddonWebhook] Add-on intent not found', [
'intent' => $intentId,
'addon_key' => $addonKey,
'transaction_id' => $transactionId,
]);
return false;
}
if ($addon->status === 'completed') {
return true; // idempotent
}
$increments = $this->catalog->resolveIncrements($addonKey);
DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) {
$addon->forceFill([
'transaction_id' => $transactionId,
'checkout_id' => $addon->checkout_id ?: $checkoutId,
'status' => 'completed',
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'),
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'),
'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]),
'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null,
'purchased_at' => now(),
])->save();
/** @var EventPackage $eventPackage */
$eventPackage = EventPackage::query()
->lockForUpdate()
->find($addon->event_package_id);
if (! $eventPackage) {
return;
}
$eventPackage->forceFill([
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($increments['extra_photos'] ?? 0) * $addon->quantity,
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($increments['extra_guests'] ?? 0) * $addon->quantity,
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity,
]);
if (($increments['extra_gallery_days'] ?? 0) > 0) {
$base = $eventPackage->gallery_expires_at ?? now();
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity);
}
$eventPackage->save();
$tenant = $addon->tenant;
if ($tenant) {
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
->notify(new AddonPurchaseReceipt($addon));
}
});
return true;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
private function extractMetadata(array $data): array
{
$metadata = [];
if (isset($data['metadata']) && is_array($data['metadata'])) {
$metadata = $data['metadata'];
}
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
$metadata = array_merge($metadata, $data['custom_data']);
}
return $metadata;
}
}

View File

@@ -77,7 +77,7 @@ class PackageLimitEvaluator
]; ];
} }
$maxPhotos = $eventPackage->package->max_photos; $maxPhotos = $eventPackage->effectivePhotoLimit();
if ($maxPhotos === null) { if ($maxPhotos === null) {
return null; return null;
@@ -115,17 +115,17 @@ class PackageLimitEvaluator
public function summarizeEventPackage(EventPackage $eventPackage): array public function summarizeEventPackage(EventPackage $eventPackage): array
{ {
$package = $eventPackage->package; $limits = $eventPackage->effectiveLimits();
$photoSummary = $this->buildUsageSummary( $photoSummary = $this->buildUsageSummary(
(int) $eventPackage->used_photos, (int) $eventPackage->used_photos,
$package?->max_photos, $limits['max_photos'],
config('package-limits.photo_thresholds', []) config('package-limits.photo_thresholds', [])
); );
$guestSummary = $this->buildUsageSummary( $guestSummary = $this->buildUsageSummary(
(int) $eventPackage->used_guests, (int) $eventPackage->used_guests,
$package?->max_guests, $limits['max_guests'],
config('package-limits.guest_thresholds', []) config('package-limits.guest_thresholds', [])
); );

View File

@@ -15,7 +15,7 @@ class PackageUsageTracker
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
{ {
$limit = $eventPackage->package?->max_photos; $limit = $eventPackage->effectivePhotoLimit();
if ($limit === null || $limit <= 0) { if ($limit === null || $limit <= 0) {
return; return;
@@ -51,7 +51,7 @@ class PackageUsageTracker
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
{ {
$limit = $eventPackage->package?->max_guests; $limit = $eventPackage->effectiveGuestLimit();
if ($limit === null || $limit <= 0) { if ($limit === null || $limit <= 0) {
return; return;

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Services\Paddle;
use App\Models\PackageAddon;
use App\Services\Paddle\Exceptions\PaddleException;
use Illuminate\Support\Arr;
class PaddleAddonCatalogService
{
public function __construct(private readonly PaddleClient $client) {}
/**
* @return array<string, mixed>
*/
public function createProduct(PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildProductPayload($addon, $overrides);
return $this->extractEntity($this->client->post('/products', $payload));
}
/**
* @return array<string, mixed>
*/
public function updateProduct(string $productId, PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildProductPayload($addon, $overrides);
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function createPrice(PackageAddon $addon, string $productId, array $overrides = []): array
{
$payload = $this->buildPricePayload($addon, $productId, $overrides);
return $this->extractEntity($this->client->post('/prices', $payload));
}
/**
* @return array<string, mixed>
*/
public function updatePrice(string $priceId, PackageAddon $addon, array $overrides = []): array
{
$payload = $this->buildPricePayload($addon, $overrides['product_id'] ?? '', $overrides);
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
}
/**
* @return array<string, mixed>
*/
public function buildProductPayload(PackageAddon $addon, array $overrides = []): array
{
$payload = array_merge([
'name' => $overrides['name'] ?? $addon->label,
'description' => $overrides['description'] ?? sprintf('Fotospiel Add-on: %s', $addon->label),
'tax_category' => $overrides['tax_category'] ?? 'standard',
'type' => $overrides['type'] ?? 'standard',
'custom_data' => array_merge([
'addon_key' => $addon->key,
'increments' => $addon->increments,
], $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['tax_category', 'type', 'custom_data', 'name', 'description']));
return $this->cleanPayload($payload);
}
/**
* @return array<string, mixed>
*/
public function buildPricePayload(PackageAddon $addon, string $productId, array $overrides = []): array
{
$unitPrice = $overrides['unit_price'] ?? $this->defaultUnitPrice($addon);
$payload = array_merge([
'product_id' => $productId,
'description' => $overrides['description'] ?? $addon->label,
'unit_price' => $unitPrice,
'custom_data' => array_merge([
'addon_key' => $addon->key,
], $overrides['custom_data'] ?? []),
], Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
return $this->cleanPayload($payload);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function extractEntity(array $payload): array
{
return Arr::get($payload, 'data', $payload);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function cleanPayload(array $payload): array
{
$filtered = collect($payload)
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
if (array_key_exists('custom_data', $filtered)) {
$filtered['custom_data'] = collect($filtered['custom_data'])
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
->all();
}
return $filtered;
}
/**
* @return array<string, string>
*/
protected function defaultUnitPrice(PackageAddon $addon): array
{
$metaPrice = $addon->metadata['price_eur'] ?? null;
if (! is_numeric($metaPrice)) {
throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
}
$amountCents = (int) round(((float) $metaPrice) * 100);
return [
'amount' => (string) $amountCents,
'currency_code' => 'EUR',
];
}
}

View File

@@ -239,11 +239,13 @@ class PhotoboothIngestService
return false; return false;
} }
$limit = $eventPackage->package->max_photos; $limit = $eventPackage->effectivePhotoLimit();
return $limit !== null if ($limit === null) {
&& $limit > 0 return false;
&& $eventPackage->used_photos >= $limit; }
return $limit > 0 && $eventPackage->used_photos >= $limit;
} }
protected function resolveEmotionId(Event $event): ?int protected function resolveEmotionId(Event $event): ?int

View File

@@ -29,7 +29,9 @@ class Mailbox
'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null, 'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null,
'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null, 'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null,
'sent_at' => now()->toIso8601String(), 'sent_at' => now()->toIso8601String(),
'headers' => (string) $event->message->getHeaders(), 'headers' => method_exists($event->message, 'getHeaders') && method_exists($event->message->getHeaders(), 'toString')
? $event->message->getHeaders()->toString()
: null,
]; ];
self::write($messages); self::write($messages);

33
config/package-addons.php Normal file
View File

@@ -0,0 +1,33 @@
<?php
return [
// Keyed add-ons with display and Paddle mapping. Amounts are base increments; multiply by quantity.
'extra_photos_small' => [
'label' => 'Extra photos (500)',
'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_SMALL_PRICE_ID'),
'increments' => [
'extra_photos' => 500,
],
],
'extra_photos_large' => [
'label' => 'Extra photos (2,000)',
'price_id' => env('PADDLE_ADDON_EXTRA_PHOTOS_LARGE_PRICE_ID'),
'increments' => [
'extra_photos' => 2000,
],
],
'extra_guests' => [
'label' => 'Extra guests (100)',
'price_id' => env('PADDLE_ADDON_EXTRA_GUESTS_PRICE_ID'),
'increments' => [
'extra_guests' => 100,
],
],
'extend_gallery_30d' => [
'label' => 'Gallery extension (30 days)',
'price_id' => env('PADDLE_ADDON_EXTEND_GALLERY_30D_PRICE_ID'),
'increments' => [
'extra_gallery_days' => 30,
],
],
];

View File

@@ -0,0 +1,88 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('event_packages', function (Blueprint $table) {
if (! Schema::hasColumn('event_packages', 'limits_snapshot')) {
$table->json('limits_snapshot')->nullable()->after('package_id');
}
if (! Schema::hasColumn('event_packages', 'extra_photos')) {
$table->integer('extra_photos')->default(0)->after('used_photos');
}
if (! Schema::hasColumn('event_packages', 'extra_guests')) {
$table->integer('extra_guests')->default(0)->after('used_guests');
}
if (! Schema::hasColumn('event_packages', 'extra_gallery_days')) {
$table->integer('extra_gallery_days')->default(0)->after('gallery_expires_at');
}
});
// Backfill snapshots from current package limits where missing.
if (Schema::hasTable('event_packages') && Schema::hasTable('packages')) {
DB::table('event_packages')
->whereNull('limits_snapshot')
->orderBy('id')
->chunkById(200, function ($rows) {
foreach ($rows as $row) {
$package = DB::table('packages')
->where('id', $row->package_id)
->first([
'max_photos',
'max_guests',
'gallery_days',
'max_tasks',
'max_events_per_year',
]);
if (! $package) {
continue;
}
$snapshot = array_filter([
'max_photos' => $package->max_photos,
'max_guests' => $package->max_guests,
'gallery_days' => $package->gallery_days,
'max_tasks' => $package->max_tasks,
'max_events_per_year' => $package->max_events_per_year,
], fn ($value) => $value !== null);
if ($snapshot === []) {
continue;
}
DB::table('event_packages')
->where('id', $row->id)
->update(['limits_snapshot' => json_encode($snapshot)]);
}
});
}
}
public function down(): void
{
Schema::table('event_packages', function (Blueprint $table) {
if (Schema::hasColumn('event_packages', 'limits_snapshot')) {
$table->dropColumn('limits_snapshot');
}
if (Schema::hasColumn('event_packages', 'extra_photos')) {
$table->dropColumn('extra_photos');
}
if (Schema::hasColumn('event_packages', 'extra_guests')) {
$table->dropColumn('extra_guests');
}
if (Schema::hasColumn('event_packages', 'extra_gallery_days')) {
$table->dropColumn('extra_gallery_days');
}
});
}
};

View File

@@ -0,0 +1,43 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('event_package_addons', function (Blueprint $table) {
$table->id();
$table->foreignId('event_package_id')->constrained()->cascadeOnDelete();
$table->foreignId('event_id')->constrained()->cascadeOnDelete();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('addon_key');
$table->integer('quantity')->default(1);
$table->integer('extra_photos')->default(0);
$table->integer('extra_guests')->default(0);
$table->integer('extra_gallery_days')->default(0);
$table->string('price_id')->nullable();
$table->string('checkout_id')->nullable();
$table->string('transaction_id')->nullable();
$table->enum('status', ['pending', 'completed', 'failed'])->default('pending');
$table->decimal('amount', 10, 2)->nullable();
$table->string('currency', 3)->nullable();
$table->json('metadata')->nullable();
$table->json('receipt_payload')->nullable();
$table->string('error')->nullable();
$table->timestamp('purchased_at')->nullable();
$table->timestamps();
$table->index(['tenant_id', 'event_id']);
$table->index(['checkout_id']);
$table->index(['transaction_id']);
});
}
public function down(): void
{
Schema::dropIfExists('event_package_addons');
}
};

View File

@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('package_addons', function (Blueprint $table) {
$table->id();
$table->string('key')->unique();
$table->string('label');
$table->string('price_id')->nullable();
$table->integer('extra_photos')->default(0);
$table->integer('extra_guests')->default(0);
$table->integer('extra_gallery_days')->default(0);
$table->boolean('active')->default(true);
$table->unsignedInteger('sort')->default(0);
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('package_addons');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('event_package_addons', function (Blueprint $table) {
if (! Schema::hasColumn('event_package_addons', 'receipt_payload')) {
$table->json('receipt_payload')->nullable()->after('metadata');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('event_package_addons', function (Blueprint $table) {
if (Schema::hasColumn('event_package_addons', 'receipt_payload')) {
$table->dropColumn('receipt_payload');
}
});
}
};

View File

@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
MediaStorageTargetSeeder::class, MediaStorageTargetSeeder::class,
LegalPagesSeeder::class, LegalPagesSeeder::class,
PackageSeeder::class, PackageSeeder::class,
PackageAddonSeeder::class,
EventTypesSeeder::class, EventTypesSeeder::class,
EmotionsSeeder::class, EmotionsSeeder::class,
TaskCollectionsSeeder::class, TaskCollectionsSeeder::class,

View File

@@ -0,0 +1,143 @@
<?php
namespace Database\Seeders;
use App\Models\PackageAddon;
use Illuminate\Database\Seeder;
class PackageAddonSeeder extends Seeder
{
public function run(): void
{
$addons = [
[
'key' => 'extra_photos_500',
'label' => '+500 Fotos',
'price_id' => null,
'extra_photos' => 500,
'extra_guests' => 0,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 10,
'metadata' => ['price_eur' => 5],
],
[
'key' => 'extra_photos_2000',
'label' => '+2.000 Fotos',
'price_id' => null,
'extra_photos' => 2000,
'extra_guests' => 0,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 11,
'metadata' => ['price_eur' => 12],
],
[
'key' => 'extra_photos_5000',
'label' => '+5.000 Fotos',
'price_id' => null,
'extra_photos' => 5000,
'extra_guests' => 0,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 12,
'metadata' => ['price_eur' => 25],
],
[
'key' => 'extra_guests_50',
'label' => '+50 Gäste',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 50,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 18,
'metadata' => ['price_eur' => 3],
],
[
'key' => 'extra_guests_100',
'label' => '+100 Gäste',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 100,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 19,
'metadata' => ['price_eur' => 5],
],
[
'key' => 'extra_guests_300',
'label' => '+300 Gäste',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 300,
'extra_gallery_days' => 0,
'active' => true,
'sort' => 20,
'metadata' => ['price_eur' => 12],
],
[
'key' => 'extend_gallery_30d',
'label' => 'Galerie +30 Tage',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 0,
'extra_gallery_days' => 30,
'active' => true,
'sort' => 30,
'metadata' => ['price_eur' => 4],
],
[
'key' => 'extend_gallery_90d',
'label' => 'Galerie +90 Tage',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 0,
'extra_gallery_days' => 90,
'active' => true,
'sort' => 31,
'metadata' => ['price_eur' => 10],
],
[
'key' => 'extend_gallery_180d',
'label' => 'Galerie +180 Tage',
'price_id' => null,
'extra_photos' => 0,
'extra_guests' => 0,
'extra_gallery_days' => 180,
'active' => true,
'sort' => 32,
'metadata' => ['price_eur' => 20],
],
[
'key' => 'event_boost_medium',
'label' => 'Event-Boost (100 Gäste, 2.000 Fotos, +30 Tage)',
'price_id' => null,
'extra_photos' => 2000,
'extra_guests' => 100,
'extra_gallery_days' => 30,
'active' => true,
'sort' => 40,
'metadata' => ['price_eur' => 18],
],
[
'key' => 'event_boost_large',
'label' => 'Event-Boost (300 Gäste, 5.000 Fotos, +90 Tage)',
'price_id' => null,
'extra_photos' => 5000,
'extra_guests' => 300,
'extra_gallery_days' => 90,
'active' => true,
'sort' => 41,
'metadata' => ['price_eur' => 38],
],
];
foreach ($addons as $addon) {
PackageAddon::updateOrCreate(
['key' => $addon['key']],
$addon,
);
}
}
}

View File

@@ -82,6 +82,7 @@ export type TenantEvent = {
expires_at: string | null; expires_at: string | null;
} | null; } | null;
limits?: EventLimitSummary | null; limits?: EventLimitSummary | null;
addons?: EventAddonSummary[];
[key: string]: unknown; [key: string]: unknown;
}; };
@@ -156,6 +157,32 @@ export type PhotoboothStatus = {
}; };
}; };
export type EventAddonCheckout = {
addon_key: string;
quantity?: number;
checkout_url: string | null;
checkout_id: string | null;
expires_at: string | null;
};
export type EventAddonCatalogItem = {
key: string;
label: string;
price_id: string | null;
increments?: Record<string, number>;
};
export type EventAddonSummary = {
id: number;
key: string;
label?: string | null;
status: 'pending' | 'completed' | 'failed';
extra_photos: number;
extra_guests: number;
extra_gallery_days: number;
purchased_at: string | null;
};
export type HelpCenterArticleSummary = { export type HelpCenterArticleSummary = {
slug: string; slug: string;
title: string; title: string;
@@ -338,6 +365,28 @@ export type PaddleTransactionSummary = {
tax?: number | null; tax?: number | null;
}; };
export type TenantAddonEventSummary = {
id: number;
slug: string;
name: string | Record<string, string> | null;
};
export type TenantAddonHistoryEntry = {
id: number;
addon_key: string;
label?: string | null;
event: TenantAddonEventSummary | null;
amount: number | null;
currency: string | null;
status: 'pending' | 'completed' | 'failed';
purchased_at: string | null;
extra_photos: number;
extra_guests: number;
extra_gallery_days: number;
quantity: number;
receipt_url?: string | null;
};
export type CreditLedgerEntry = { export type CreditLedgerEntry = {
id: number; id: number;
delta: number; delta: number;
@@ -829,6 +878,48 @@ function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary
}; };
} }
function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry {
let event: TenantAddonEventSummary | null = null;
if (entry.event && typeof entry.event === 'object') {
const rawEvent = entry.event as JsonValue;
const id = Number((rawEvent as { id?: unknown }).id ?? 0);
const slugValue = (rawEvent as { slug?: unknown }).slug;
const rawName = (rawEvent as { name?: unknown }).name ?? null;
let name: TenantAddonEventSummary['name'] = null;
if (typeof rawName === 'string') {
name = rawName;
} else if (rawName && typeof rawName === 'object') {
name = normalizeTranslationMap(rawName, undefined, true);
}
event = {
id,
slug: typeof slugValue === 'string' ? slugValue : '',
name,
};
}
const amountValue = entry.amount;
return {
id: Number(entry.id ?? 0),
addon_key: String(entry.addon_key ?? ''),
label: typeof entry.label === 'string' ? entry.label : null,
event,
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
currency: typeof entry.currency === 'string' ? entry.currency : null,
status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending',
purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null,
extra_photos: Number(entry.extra_photos ?? 0),
extra_guests: Number(entry.extra_guests ?? 0),
extra_gallery_days: Number(entry.extra_gallery_days ?? 0),
quantity: Number(entry.quantity ?? 1),
receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : null,
};
}
function normalizeTask(task: JsonValue): TenantTask { function normalizeTask(task: JsonValue): TenantTask {
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {}); const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {}); const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
@@ -1122,6 +1213,28 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
return normalizeEvent(data.data); return normalizeEvent(data.data);
} }
export async function createEventAddonCheckout(
eventSlug: string,
params: { addon_key: string; quantity?: number; success_url?: string; cancel_url?: string }
): Promise<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }> {
const response = await authorizedFetch(`${eventEndpoint(eventSlug)}/addons/checkout`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
return await jsonOrThrow<{ checkout_url: string | null; checkout_id: string | null; expires_at: string | null }>(
response,
'Failed to create addon checkout'
);
}
export async function getAddonCatalog(): Promise<EventAddonCatalogItem[]> {
const response = await authorizedFetch('/api/v1/tenant/addons/catalog');
const data = await jsonOrThrow<{ data?: EventAddonCatalogItem[] }>(response, 'Failed to load add-ons');
return data.data ?? [];
}
export async function getEventTypes(): Promise<TenantEventType[]> { export async function getEventTypes(): Promise<TenantEventType[]> {
const response = await authorizedFetch('/api/v1/tenant/event-types'); const response = await authorizedFetch('/api/v1/tenant/event-types');
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types'); const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
@@ -1675,6 +1788,42 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
}; };
} }
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
data: TenantAddonHistoryEntry[];
meta: PaginationMeta;
}> {
const params = new URLSearchParams({
page: String(Math.max(1, page)),
per_page: String(Math.max(1, Math.min(perPage, 100))),
});
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
if (response.status === 404) {
return {
data: [],
meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 },
};
}
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<PaginationMeta>; current_page?: number; last_page?: number; per_page?: number; total?: number }>(
response,
'Failed to load add-on history'
);
const rows = Array.isArray(payload.data) ? payload.data.map((row) => normalizeTenantAddonHistoryEntry(row)) : [];
const metaSource = payload.meta ?? payload;
const meta: PaginationMeta = {
current_page: Number(metaSource.current_page ?? 1),
last_page: Number(metaSource.last_page ?? 1),
per_page: Number(metaSource.per_page ?? perPage),
total: Number(metaSource.total ?? rows.length),
};
return { data: rows, meta };
}
export async function getCreditBalance(): Promise<CreditBalance> { export async function getCreditBalance(): Promise<CreditBalance> {
const response = await authorizedFetch('/api/v1/tenant/credits/balance'); const response = await authorizedFetch('/api/v1/tenant/credits/balance');
if (response.status === 404) { if (response.status === 404) {

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import type { EventAddonSummary } from '../../api';
type Props = {
addons: EventAddonSummary[];
t: (key: string, fallback: string) => string;
};
export function AddonSummaryList({ addons, t }: Props) {
if (!addons.length) {
return null;
}
return (
<div className="space-y-3">
{addons.map((addon) => (
<div key={addon.id} className="flex flex-col gap-1 rounded-2xl border border-slate-200/70 bg-white/70 p-4 text-sm dark:border-white/10 dark:bg-white/5 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="font-semibold text-slate-900 dark:text-white">{addon.label ?? addon.key}</p>
<p className="text-xs text-slate-500">
{buildSummary(addon, t)}
</p>
{addon.purchased_at ? (
<p className="text-xs text-slate-400">
{t('events.sections.addons.purchasedAt', `Purchased ${new Date(addon.purchased_at).toLocaleString()}`, {
date: new Date(addon.purchased_at).toLocaleString(),
})}
</p>
) : null}
</div>
<Badge variant={addon.status === 'completed' ? 'outline' : addon.status === 'pending' ? 'secondary' : 'destructive'}>
{t(`events.sections.addons.status.${addon.status}`, addon.status)}
</Badge>
</div>
))}
</div>
);
}
function buildSummary(addon: EventAddonSummary, t: (key: string, fallback: string, options?: Record<string, unknown>) => string): string {
const parts: string[] = [];
if (addon.extra_photos > 0) {
parts.push(
t('events.sections.addons.summary.photos', `+${addon.extra_photos} photos`, {
count: addon.extra_photos.toLocaleString(),
}),
);
}
if (addon.extra_guests > 0) {
parts.push(
t('events.sections.addons.summary.guests', `+${addon.extra_guests} guests`, {
count: addon.extra_guests.toLocaleString(),
}),
);
}
if (addon.extra_gallery_days > 0) {
parts.push(
t('events.sections.addons.summary.gallery', `+${addon.extra_gallery_days} days gallery`, {
count: addon.extra_gallery_days,
}),
);
}
return parts.join(' · ');
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { ShoppingCart } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { EventAddonCatalogItem } from '../../api';
type Props = {
addons: EventAddonCatalogItem[];
scope: 'photos' | 'guests' | 'gallery';
onCheckout: (addonKey: string) => void;
busy?: boolean;
t: (key: string, fallback: string) => string;
};
const scopeDefaults: Record<Props['scope'], string[]> = {
photos: ['extra_photos_500', 'extra_photos_2000'],
guests: ['extra_guests_50', 'extra_guests_100'],
gallery: ['extend_gallery_30d', 'extend_gallery_90d'],
};
export function AddonsPicker({ addons, scope, onCheckout, busy, t }: Props) {
const options = React.useMemo(() => {
const whitelist = scopeDefaults[scope];
const filtered = addons.filter((addon) => whitelist.includes(addon.key));
return filtered.length ? filtered : addons;
}, [addons, scope]);
const [selected, setSelected] = React.useState<string | undefined>(() => options[0]?.key);
React.useEffect(() => {
setSelected(options[0]?.key);
}, [options]);
if (!options.length) {
return null;
}
return (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Select value={selected} onValueChange={(value) => setSelected(value)}>
<SelectTrigger className="w-full sm:w-64">
<SelectValue placeholder={t('addons.selectPlaceholder', 'Add-on auswählen')} />
</SelectTrigger>
<SelectContent>
{options.map((addon) => (
<SelectItem key={addon.key} value={addon.key} disabled={!addon.price_id}>
{addon.label}
{!addon.price_id ? ' (kein Preis verknüpft)' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={!selected || busy || !options.find((a) => a.key === selected)?.price_id}
onClick={() => selected && onCheckout(selected)}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('addons.buyNow', 'Jetzt freischalten')}
</Button>
</div>
);
}

View File

@@ -72,6 +72,9 @@
"galleryWarningDay": "Galerie läuft in {days} Tag ab.", "galleryWarningDay": "Galerie läuft in {days} Tag ab.",
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.", "galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
"galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.", "galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.",
"unlimited": "Unbegrenzt" "unlimited": "Unbegrenzt",
"buyMorePhotos": "Mehr Fotos freischalten",
"buyMoreGuests": "Mehr Gäste freischalten",
"extendGallery": "Galerie verlängern"
} }
} }

View File

@@ -80,6 +80,32 @@
"loadingMore": "Laden…" "loadingMore": "Laden…"
} }
}, },
"addOns": {
"title": "Add-on-Verlauf",
"description": "Einmalige Add-ons, die für diesen Tenant gebucht wurden.",
"empty": "Noch keine Add-ons gebucht.",
"badge": "Add-ons",
"table": {
"addon": "Add-on",
"event": "Event",
"amount": "Betrag",
"status": "Status",
"purchased": "Gekauft",
"eventFallback": "Event archiviert"
},
"status": {
"pending": "In Bearbeitung",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen"
},
"extras": {
"photos": "+{{count}} Fotos",
"guests": "+{{count}} Gäste",
"gallery": "+{{count}} Galerietage"
},
"loadMore": "Weitere Add-ons laden",
"loadingMore": "Add-ons werden geladen…"
},
"packages": { "packages": {
"title": "Paket-Historie", "title": "Paket-Historie",
"description": "Übersicht über aktive und vergangene Pakete.", "description": "Übersicht über aktive und vergangene Pakete.",
@@ -382,7 +408,8 @@
"backToEvent": "Event öffnen", "backToEvent": "Event öffnen",
"copy": "Link kopieren", "copy": "Link kopieren",
"copied": "Kopiert!", "copied": "Kopiert!",
"deactivate": "Deaktivieren" "deactivate": "Deaktivieren",
"buyMoreGuests": "Mehr Gäste freischalten"
}, },
"labels": { "labels": {
"usage": "Nutzung", "usage": "Nutzung",
@@ -511,11 +538,16 @@
"loadFailed": "Event konnte nicht geladen werden.", "loadFailed": "Event konnte nicht geladen werden.",
"notFoundTitle": "Event nicht gefunden", "notFoundTitle": "Event nicht gefunden",
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.", "notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
"toggleFailed": "Status konnte nicht angepasst werden." "toggleFailed": "Status konnte nicht angepasst werden.",
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
}, },
"alerts": { "alerts": {
"failedTitle": "Aktion fehlgeschlagen" "failedTitle": "Aktion fehlgeschlagen"
}, },
"success": {
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
},
"placeholders": { "placeholders": {
"untitled": "Unbenanntes Event" "untitled": "Unbenanntes Event"
}, },
@@ -526,7 +558,10 @@
"tasks": "Aufgaben verwalten", "tasks": "Aufgaben verwalten",
"invites": "Einladungen & Layouts", "invites": "Einladungen & Layouts",
"photos": "Fotos moderieren", "photos": "Fotos moderieren",
"refresh": "Aktualisieren" "refresh": "Aktualisieren",
"buyMorePhotos": "Mehr Fotos freischalten",
"buyMoreGuests": "Mehr Gäste freischalten",
"extendGallery": "Galerie verlängern"
}, },
"workspace": { "workspace": {
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.", "detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
@@ -552,6 +587,23 @@
"activeYes": "Ja", "activeYes": "Ja",
"activeNo": "Nein" "activeNo": "Nein"
}, },
"sections": {
"addons": {
"title": "Add-ons & Upgrades",
"description": "Zuletzt gebuchte Add-ons für dieses Event.",
"status": {
"completed": "Aktiv",
"pending": "In Bearbeitung",
"failed": "Fehlgeschlagen"
},
"purchasedAt": "Gekauft {{date}}",
"summary": {
"photos": "+{{count}} Fotos",
"guests": "+{{count}} Gäste",
"gallery": "+{{count}} Tage Galerie"
}
}
},
"status": { "status": {
"published": "Veröffentlicht", "published": "Veröffentlicht",
"draft": "Entwurf", "draft": "Entwurf",

View File

@@ -72,6 +72,9 @@
"galleryWarningDay": "Gallery expires in {days} day.", "galleryWarningDay": "Gallery expires in {days} day.",
"galleryWarningDays": "Gallery expires in {days} days.", "galleryWarningDays": "Gallery expires in {days} days.",
"galleryExpired": "Gallery has expired. Guests can no longer access the photos.", "galleryExpired": "Gallery has expired. Guests can no longer access the photos.",
"unlimited": "Unlimited" "unlimited": "Unlimited",
"buyMorePhotos": "Unlock more photos",
"buyMoreGuests": "Unlock more guests",
"extendGallery": "Extend gallery"
} }
} }

View File

@@ -80,6 +80,32 @@
"loadingMore": "Loading…" "loadingMore": "Loading…"
} }
}, },
"addOns": {
"title": "Add-on history",
"description": "One-time add-ons purchased for this tenant.",
"empty": "No add-ons purchased yet.",
"badge": "Add-ons",
"table": {
"addon": "Add-on",
"event": "Event",
"amount": "Amount",
"status": "Status",
"purchased": "Purchased",
"eventFallback": "Event archived"
},
"status": {
"pending": "Processing",
"completed": "Completed",
"failed": "Failed"
},
"extras": {
"photos": "+{{count}} photos",
"guests": "+{{count}} guests",
"gallery": "+{{count}} gallery days"
},
"loadMore": "Load more add-ons",
"loadingMore": "Loading add-ons…"
},
"packages": { "packages": {
"title": "Package history", "title": "Package history",
"description": "Overview of current and past packages.", "description": "Overview of current and past packages.",
@@ -382,7 +408,8 @@
"backToEvent": "Open event", "backToEvent": "Open event",
"copy": "Copy link", "copy": "Copy link",
"copied": "Copied!", "copied": "Copied!",
"deactivate": "Deactivate" "deactivate": "Deactivate",
"buyMoreGuests": "Unlock more guests"
}, },
"labels": { "labels": {
"usage": "Usage", "usage": "Usage",
@@ -511,11 +538,16 @@
"loadFailed": "Event could not be loaded.", "loadFailed": "Event could not be loaded.",
"notFoundTitle": "Event not found", "notFoundTitle": "Event not found",
"notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.", "notFoundBody": "Without a valid identifier we cant load the data. Return to the list and choose an event.",
"toggleFailed": "Status could not be updated." "toggleFailed": "Status could not be updated.",
"checkoutMissing": "Checkout could not be started.",
"checkoutFailed": "Add-on checkout failed."
}, },
"alerts": { "alerts": {
"failedTitle": "Action failed" "failedTitle": "Action failed"
}, },
"success": {
"addonApplied": "Add-on applied. Limits will refresh shortly."
},
"placeholders": { "placeholders": {
"untitled": "Untitled event" "untitled": "Untitled event"
}, },
@@ -526,7 +558,10 @@
"tasks": "Manage tasks", "tasks": "Manage tasks",
"invites": "Invites & layouts", "invites": "Invites & layouts",
"photos": "Moderate photos", "photos": "Moderate photos",
"refresh": "Refresh" "refresh": "Refresh",
"buyMorePhotos": "Unlock more photos",
"buyMoreGuests": "Unlock more guests",
"extendGallery": "Extend gallery"
}, },
"workspace": { "workspace": {
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.", "detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
@@ -552,6 +587,23 @@
"activeYes": "Yes", "activeYes": "Yes",
"activeNo": "No" "activeNo": "No"
}, },
"sections": {
"addons": {
"title": "Add-ons & Boosts",
"description": "Recently purchased add-ons for this event.",
"status": {
"completed": "Active",
"pending": "Processing",
"failed": "Failed"
},
"purchasedAt": "Purchased {{date}}",
"summary": {
"photos": "+{{count}} photos",
"guests": "+{{count}} guests",
"gallery": "+{{count}} days gallery"
}
}
},
"status": { "status": {
"published": "Published", "published": "Published",
"draft": "Draft", "draft": "Draft",

View File

@@ -8,7 +8,15 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api'; import {
getTenantPackagesOverview,
getTenantPaddleTransactions,
getTenantAddonHistory,
PaddleTransactionSummary,
TenantAddonHistoryEntry,
TenantPackageSummary,
PaginationMeta,
} from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { import {
TenantHeroCard, TenantHeroCard,
@@ -34,6 +42,9 @@ export default function BillingPage() {
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null); const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
const [transactionsHasMore, setTransactionsHasMore] = React.useState(false); const [transactionsHasMore, setTransactionsHasMore] = React.useState(false);
const [transactionsLoading, setTransactionsLoading] = React.useState(false); const [transactionsLoading, setTransactionsLoading] = React.useState(false);
const [addonHistory, setAddonHistory] = React.useState<TenantAddonHistoryEntry[]>([]);
const [addonMeta, setAddonMeta] = React.useState<PaginationMeta | null>(null);
const [addonsLoading, setAddonsLoading] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -55,6 +66,33 @@ export default function BillingPage() {
[locale] [locale]
); );
const resolveEventName = React.useCallback(
(event: TenantAddonHistoryEntry['event']) => {
const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed');
if (!event) {
return fallback;
}
if (typeof event.name === 'string' && event.name.trim().length > 0) {
return event.name;
}
if (event.name && typeof event.name === 'object') {
const lang = i18n.language?.split('-')[0] ?? 'de';
return (
event.name[lang] ??
event.name.de ??
event.name.en ??
Object.values(event.name)[0] ??
fallback
);
}
return fallback;
},
[i18n.language, t]
);
const packageLabels = React.useMemo( const packageLabels = React.useMemo(
() => ({ () => ({
statusActive: t('billing.sections.packages.card.statusActive'), statusActive: t('billing.sections.packages.card.statusActive'),
@@ -70,18 +108,24 @@ export default function BillingPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const [packagesResult, paddleTransactions] = await Promise.all([ const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([
getTenantPackagesOverview(force ? { force: true } : undefined), getTenantPackagesOverview(force ? { force: true } : undefined),
getTenantPaddleTransactions().catch((err) => { getTenantPaddleTransactions().catch((err) => {
console.warn('Failed to load Paddle transactions', err); console.warn('Failed to load Paddle transactions', err);
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false }; return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
}), }),
getTenantAddonHistory().catch((err) => {
console.warn('Failed to load add-on history', err);
return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } };
}),
]); ]);
setPackages(packagesResult.packages); setPackages(packagesResult.packages);
setActivePackage(packagesResult.activePackage); setActivePackage(packagesResult.activePackage);
setTransactions(paddleTransactions.data); setTransactions(paddleTransactions.data);
setTransactionCursor(paddleTransactions.nextCursor); setTransactionCursor(paddleTransactions.nextCursor);
setTransactionsHasMore(paddleTransactions.hasMore); setTransactionsHasMore(paddleTransactions.hasMore);
setAddonHistory(addonHistoryResult.data);
setAddonMeta(addonHistoryResult.meta);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(t('billing.errors.load')); setError(t('billing.errors.load'));
@@ -110,6 +154,24 @@ export default function BillingPage() {
} }
}, [transactionCursor, transactionsHasMore, transactionsLoading]); }, [transactionCursor, transactionsHasMore, transactionsLoading]);
const loadMoreAddons = React.useCallback(async () => {
if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) {
return;
}
setAddonsLoading(true);
try {
const nextPage = addonMeta.current_page + 1;
const result = await getTenantAddonHistory(nextPage);
setAddonHistory((current) => [...current, ...result.data]);
setAddonMeta(result.meta);
} catch (error) {
console.warn('Failed to load additional add-on history', error);
} finally {
setAddonsLoading(false);
}
}, [addonMeta, addonsLoading]);
React.useEffect(() => { React.useEffect(() => {
void loadAll(); void loadAll();
}, [loadAll]); }, [loadAll]);
@@ -118,6 +180,12 @@ export default function BillingPage() {
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'), () => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
[activePackage, t, formatDate], [activePackage, t, formatDate],
); );
const hasMoreAddons = React.useMemo(() => {
if (!addonMeta) {
return false;
}
return addonMeta.current_page < addonMeta.last_page;
}, [addonMeta]);
const heroBadge = t('billing.hero.badge', 'Abrechnung'); const heroBadge = t('billing.hero.badge', 'Abrechnung');
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.'); const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
@@ -288,6 +356,38 @@ export default function BillingPage() {
</div> </div>
</SectionCard> </SectionCard>
<SectionCard className="space-y-4">
<SectionHeader
eyebrow={t('billing.sections.addOns.badge', 'Add-ons')}
title={t('billing.sections.addOns.title')}
description={t('billing.sections.addOns.description')}
/>
{addonHistory.length === 0 ? (
<EmptyState message={t('billing.sections.addOns.empty')} />
) : (
<AddonHistoryTable
items={addonHistory}
formatCurrency={formatCurrency}
formatDate={formatDate}
resolveEventName={resolveEventName}
locale={locale}
t={t}
/>
)}
{hasMoreAddons && (
<Button variant="outline" onClick={() => void loadMoreAddons()} disabled={addonsLoading}>
{addonsLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('billing.sections.addOns.loadingMore', 'Loading add-ons...')}
</>
) : (
t('billing.sections.addOns.loadMore', 'Load more add-ons')
)}
</Button>
)}
</SectionCard>
<SectionCard className="space-y-4"> <SectionCard className="space-y-4">
<SectionHeader <SectionHeader
eyebrow={t('billing.sections.transactions.badge', 'Transaktionen')} eyebrow={t('billing.sections.transactions.badge', 'Transaktionen')}
@@ -336,6 +436,118 @@ export default function BillingPage() {
); );
} }
function AddonHistoryTable({
items,
formatCurrency,
formatDate,
resolveEventName,
locale,
t,
}: {
items: TenantAddonHistoryEntry[];
formatCurrency: (value: number | null | undefined, currency?: string) => string;
formatDate: (value: string | null | undefined) => string;
resolveEventName: (event: TenantAddonHistoryEntry['event']) => string;
locale: string;
t: (key: string, options?: Record<string, unknown>) => string;
}) {
const extrasLabel = (key: 'photos' | 'guests' | 'gallery', count: number) =>
t(`billing.sections.addOns.extras.${key}`, { count });
return (
<FrostedSurface className="overflow-x-auto border border-slate-200/60 p-0 dark:border-slate-800/70">
<table className="min-w-full divide-y divide-slate-200 text-sm dark:divide-slate-800">
<thead className="bg-slate-50/60 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:bg-slate-900/20 dark:text-slate-400">
<tr>
<th className="px-4 py-3">{t('billing.sections.addOns.table.addon')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.event')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.amount')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.status')}</th>
<th className="px-4 py-3">{t('billing.sections.addOns.table.purchased')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 dark:divide-slate-800/70">
{items.map((item) => {
const extras: string[] = [];
if (item.extra_photos > 0) {
extras.push(extrasLabel('photos', item.extra_photos));
}
if (item.extra_guests > 0) {
extras.push(extrasLabel('guests', item.extra_guests));
}
if (item.extra_gallery_days > 0) {
extras.push(extrasLabel('gallery', item.extra_gallery_days));
}
const purchasedLabel = item.purchased_at
? new Date(item.purchased_at).toLocaleString(locale, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: formatDate(item.purchased_at);
const statusKey = `billing.sections.addOns.status.${item.status}`;
const statusLabel = t(statusKey, { defaultValue: item.status });
const statusTone: Record<string, string> = {
completed: 'bg-emerald-500/15 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200',
pending: 'bg-amber-500/15 text-amber-700 dark:bg-amber-500/20 dark:text-amber-200',
failed: 'bg-rose-500/15 text-rose-700 dark:bg-rose-500/20 dark:text-rose-200',
};
return (
<tr key={item.id} className="bg-white even:bg-slate-50/40 dark:bg-slate-950/50 dark:even:bg-slate-900/40">
<td className="px-4 py-3 align-top">
<div className="flex items-center gap-2 text-slate-900 dark:text-slate-100">
<span className="font-semibold">{item.label ?? item.addon_key}</span>
{item.quantity > 1 ? (
<Badge variant="outline" className="border-slate-200/70 text-[11px] font-medium dark:border-slate-700">
×{item.quantity}
</Badge>
) : null}
</div>
{extras.length > 0 ? (
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">{extras.join(' · ')}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-medium text-slate-800 dark:text-slate-200">{resolveEventName(item.event)}</p>
{item.event?.slug ? (
<p className="text-xs text-slate-500 dark:text-slate-500">{item.event.slug}</p>
) : null}
</td>
<td className="px-4 py-3 align-top">
<p className="font-semibold text-slate-900 dark:text-slate-100">
{formatCurrency(item.amount, item.currency ?? 'EUR')}
</p>
{item.receipt_url ? (
<a
href={item.receipt_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
>
{t('billing.sections.transactions.labels.receipt')}
</a>
) : null}
</td>
<td className="px-4 py-3 align-top">
<Badge className={statusTone[item.status] ?? 'bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200'}>
{statusLabel}
</Badge>
</td>
<td className="px-4 py-3 align-top text-sm text-slate-600 dark:text-slate-300">{purchasedLabel}</td>
</tr>
);
})}
</tbody>
</table>
</FrostedSurface>
);
}
function TransactionCard({ function TransactionCard({
transaction, transaction,
formatCurrency, formatCurrency,

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
AlertTriangle, AlertTriangle,
@@ -16,6 +16,7 @@ import {
RefreshCw, RefreshCw,
Smile, Smile,
Sparkles, Sparkles,
ShoppingCart,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
@@ -36,6 +37,7 @@ import {
toggleEvent, toggleEvent,
submitTenantFeedback, submitTenantFeedback,
updatePhotoVisibility, updatePhotoVisibility,
createEventAddonCheckout,
} from '../api'; } from '../api';
import { buildLimitWarnings } from '../lib/limitWarnings'; import { buildLimitWarnings } from '../lib/limitWarnings';
import { getApiErrorMessage } from '../lib/apiError'; import { getApiErrorMessage } from '../lib/apiError';
@@ -54,6 +56,9 @@ import {
ActionGrid, ActionGrid,
TenantHeroCard, TenantHeroCard,
} from '../components/tenant'; } from '../components/tenant';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
import { GuestBroadcastCard } from '../components/GuestBroadcastCard'; import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
type EventDetailPageProps = { type EventDetailPageProps = {
@@ -76,6 +81,7 @@ type WorkspaceState = {
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) { export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { t: tCommon } = useTranslation('common'); const { t: tCommon } = useTranslation('common');
@@ -91,6 +97,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
}); });
const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null }); const [toolkit, setToolkit] = React.useState<ToolkitState>({ data: null, loading: true, error: null });
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const [addonRefreshCount, setAddonRefreshCount] = React.useState(0);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -103,8 +112,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
setToolkit((prev) => ({ ...prev, loading: true, error: null })); setToolkit((prev) => ({ ...prev, loading: true, error: null }));
try { try {
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]); const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false })); setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
setAddonsCatalog(addonOptions);
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState((prev) => ({ setState((prev) => ({
@@ -182,6 +192,42 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
); );
const shownWarningToasts = React.useRef<Set<string>>(new Set()); const shownWarningToasts = React.useRef<Set<string>>(new Set());
const [addonBusyId, setAddonBusyId] = React.useState<string | null>(null);
const handleAddonPurchase = React.useCallback(
async (scope: 'photos' | 'guests' | 'gallery', addonKeyOverride?: string) => {
if (!slug) return;
const defaultAddons: Record<typeof scope, string> = {
photos: 'extra_photos_500',
guests: 'extra_guests_100',
gallery: 'extend_gallery_30d',
};
const addonKey = addonKeyOverride ?? defaultAddons[scope];
setAddonBusyId(scope);
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
} else {
toast(t('events.errors.checkoutMissing', 'Checkout konnte nicht gestartet werden.'));
}
} catch (err) {
toast(getApiErrorMessage(err, t('events.errors.checkoutFailed', 'Add-on Checkout fehlgeschlagen.')));
} finally {
setAddonBusyId(null);
}
},
[slug, t],
);
React.useEffect(() => { React.useEffect(() => {
limitWarnings.forEach((warning) => { limitWarnings.forEach((warning) => {
@@ -198,6 +244,30 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
}); });
}, [limitWarnings]); }, [limitWarnings]);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
setAddonRefreshCount(3);
const params = new URLSearchParams(window.location.search);
params.delete('addon_success');
const search = params.toString();
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
React.useEffect(() => {
if (addonRefreshCount <= 0) {
return;
}
const timer = setTimeout(() => {
void load();
setAddonRefreshCount((count) => count - 1);
}, 8000);
return () => clearTimeout(timer);
}, [addonRefreshCount, load]);
if (!slug) { if (!slug) {
return ( return (
<AdminLayout <AdminLayout
@@ -230,10 +300,39 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
variant={warning.tone === 'danger' ? 'destructive' : 'default'} variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined} className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertDescription className="flex items-center gap-2 text-sm"> <AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{warning.message} {warning.message}
</AlertDescription> </AlertDescription>
{(['photos', 'guests', 'gallery'] as const).includes(warning.scope) ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery'); }}
disabled={addonBusyId === warning.scope}
className="justify-start"
>
<ShoppingCart className="mr-2 h-4 w-4" />
{warning.scope === 'photos'
? t('events.actions.buyMorePhotos', 'Mehr Fotos freischalten')
: warning.scope === 'guests'
? t('events.actions.buyMoreGuests', 'Mehr Gäste freischalten')
: t('events.actions.extendGallery', 'Galerie verlängern')}
</Button>
{addonsCatalog.length > 0 ? (
<AddonsPicker
addons={addonsCatalog}
scope={warning.scope as 'photos' | 'guests' | 'gallery'}
onCheckout={(key) => { void handleAddonPurchase(warning.scope as 'photos' | 'guests' | 'gallery', key); }}
busy={addonBusyId === warning.scope}
t={(key, fallback) => t(key as any, fallback)}
/>
) : null}
</div>
) : null}
</div>
</Alert> </Alert>
))} ))}
</div> </div>
@@ -259,6 +358,16 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
{(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />} {(toolkitData?.alerts?.length ?? 0) > 0 && <AlertList alerts={toolkitData?.alerts ?? []} />}
{state.event?.addons?.length ? (
<SectionCard>
<SectionHeader
title={t('events.sections.addons.title', 'Add-ons & Upgrades')}
description={t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}
/>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key, fallback)} />
</SectionCard>
) : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]"> <div className="grid gap-6 xl:grid-cols-[minmax(0,1.6fr)_minmax(0,0.6fr)]">
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} /> <StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
<QuickActionsCard <QuickActionsCard

View File

@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react'; import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import toast from 'react-hot-toast';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { import {
@@ -20,6 +21,9 @@ import {
TenantEvent, TenantEvent,
updateEventQrInvite, updateEventQrInvite,
EventQrInviteLayout, EventQrInviteLayout,
createEventAddonCheckout,
getAddonCatalog,
type EventAddonCatalogItem,
} from '../api'; } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { import {
@@ -29,6 +33,8 @@ import {
ADMIN_EVENT_PHOTOS_PATH, ADMIN_EVENT_PHOTOS_PATH,
} from '../constants'; } from '../constants';
import { buildLimitWarnings } from '../lib/limitWarnings'; import { buildLimitWarnings } from '../lib/limitWarnings';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel'; import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames'; import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas'; import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
@@ -191,9 +197,14 @@ export default function EventInvitesPage(): React.ReactElement {
setState((prev) => ({ ...prev, loading: true, error: null })); setState((prev) => ({ ...prev, loading: true, error: null }));
try { try {
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]); const [eventData, invitesData, catalog] = await Promise.all([
getEvent(slug),
getEventQrInvites(slug),
getAddonCatalog(),
]);
setState({ event: eventData, invites: invitesData, loading: false, error: null }); setState({ event: eventData, invites: invitesData, loading: false, error: null });
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null); setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
setAddonsCatalog(catalog);
} catch (error) { } catch (error) {
if (!isAuthError(error)) { if (!isAuthError(error)) {
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' }); setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
@@ -765,6 +776,36 @@ export default function EventInvitesPage(): React.ReactElement {
[state.event?.limits, tLimits] [state.event?.limits, tLimits]
); );
const [addonBusy, setAddonBusy] = React.useState<string | null>(null);
const [addonsCatalog, setAddonsCatalog] = React.useState<EventAddonCatalogItem[]>([]);
const [searchParams] = useSearchParams();
const handleAddonPurchase = React.useCallback(
async (addonKey?: string) => {
if (!slug) return;
setAddonBusy('guests');
const key = addonKey ?? 'extra_guests_100';
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(slug, {
addon_key: key,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
}
} catch (err) {
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
} finally {
setAddonBusy(null);
}
},
[slug],
);
const limitScopeLabels = React.useMemo( const limitScopeLabels = React.useMemo(
() => ({ () => ({
photos: tLimits('photosTitle'), photos: tLimits('photosTitle'),
@@ -774,6 +815,16 @@ export default function EventInvitesPage(): React.ReactElement {
[tLimits] [tLimits]
); );
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
void load();
searchParams.delete('addon_success');
navigate(window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, t]);
return ( return (
<AdminLayout <AdminLayout
title={eventName} title={eventName}
@@ -788,6 +839,8 @@ export default function EventInvitesPage(): React.ReactElement {
variant={warning.tone === 'danger' ? 'destructive' : 'default'} variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined} className={warning.tone === 'warning' ? 'border-amber-400/50 bg-amber-50 text-amber-900' : undefined}
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<AlertTitle className="flex items-center gap-2 text-sm font-semibold"> <AlertTitle className="flex items-center gap-2 text-sm font-semibold">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{limitScopeLabels[warning.scope]} {limitScopeLabels[warning.scope]}
@@ -795,11 +848,45 @@ export default function EventInvitesPage(): React.ReactElement {
<AlertDescription className="text-sm"> <AlertDescription className="text-sm">
{warning.message} {warning.message}
</AlertDescription> </AlertDescription>
</div>
{warning.scope === 'guests' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleAddonPurchase(); }}
disabled={addonBusy === 'guests'}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{t('invites.actions.buyMoreGuests', 'Mehr Gäste freischalten')}
</Button>
<AddonsPicker
addons={addonsCatalog}
scope="guests"
onCheckout={(key) => { void handleAddonPurchase(key); }}
busy={addonBusy === 'guests'}
t={(key, fallback) => t(key as any, fallback)}
/>
</div>
) : null}
</div>
</Alert> </Alert>
))} ))}
</div> </div>
)} )}
{state.event?.addons?.length ? (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={state.event.addons} t={(key, fallback) => t(key as any, fallback)} />
</CardContent>
</Card>
) : null}
<Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6"> <Tabs value={activeTab} onValueChange={handleTabChange} className="space-y-6">
<TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm"> <TabsList className="grid w-full max-w-2xl grid-cols-3 gap-1 rounded-full border border-[var(--tenant-border-strong)] bg-[var(--tenant-surface-strong)] p-1 text-sm">
<TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground"> <TabsTrigger value="layout" className="rounded-full px-4 py-1.5 data-[state=active]:bg-primary data-[state=active]:text-primary-foreground">

View File

@@ -1,13 +1,17 @@
import React from 'react'; import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react'; import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import toast from 'react-hot-toast';
import { AddonsPicker } from '../components/Addons/AddonsPicker';
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
import { AdminLayout } from '../components/AdminLayout'; import { AdminLayout } from '../components/AdminLayout';
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api'; import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
import { isAuthError } from '../auth/tokens'; import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage, isApiError } from '../lib/apiError'; import { getApiErrorMessage, isApiError } from '../lib/apiError';
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings'; import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
@@ -31,6 +35,10 @@ export default function EventPhotosPage() {
const [error, setError] = React.useState<string | undefined>(undefined); const [error, setError] = React.useState<string | undefined>(undefined);
const [busyId, setBusyId] = React.useState<number | null>(null); const [busyId, setBusyId] = React.useState<number | null>(null);
const [limits, setLimits] = React.useState<EventLimitSummary | null>(null); const [limits, setLimits] = React.useState<EventLimitSummary | null>(null);
const [addons, setAddons] = React.useState<EventAddonCatalogItem[]>([]);
const [catalogError, setCatalogError] = React.useState<string | undefined>(undefined);
const [searchParams, setSearchParams] = React.useState(() => new URLSearchParams(window.location.search));
const [eventAddons, setEventAddons] = React.useState<EventAddonSummary[]>([]);
const load = React.useCallback(async () => { const load = React.useCallback(async () => {
if (!slug) { if (!slug) {
@@ -40,9 +48,16 @@ export default function EventPhotosPage() {
setLoading(true); setLoading(true);
setError(undefined); setError(undefined);
try { try {
const result = await getEventPhotos(slug); const [photoResult, eventData, catalog] = await Promise.all([
setPhotos(result.photos); getEventPhotos(slug),
setLimits(result.limits ?? null); getEvent(slug),
getAddonCatalog(),
]);
setPhotos(photoResult.photos);
setLimits(photoResult.limits ?? null);
setEventAddons(eventData.addons ?? []);
setAddons(catalog);
setCatalogError(undefined);
} catch (err) { } catch (err) {
if (!isAuthError(err)) { if (!isAuthError(err)) {
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.')); setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
@@ -56,6 +71,18 @@ export default function EventPhotosPage() {
load(); load();
}, [load]); }, [load]);
React.useEffect(() => {
const success = searchParams.get('addon_success');
if (success && slug) {
toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' }));
void load();
const params = new URLSearchParams(searchParams);
params.delete('addon_success');
setSearchParams(params);
navigate(window.location.pathname, { replace: true });
}
}, [searchParams, slug, load, navigate, translateLimits]);
async function handleToggleFeature(photo: TenantPhoto) { async function handleToggleFeature(photo: TenantPhoto) {
if (!slug) return; if (!slug) return;
setBusyId(photo.id); setBusyId(photo.id);
@@ -126,7 +153,19 @@ export default function EventPhotosPage() {
</Alert> </Alert>
)} )}
<LimitWarningsBanner limits={limits} translate={translateLimits} /> <LimitWarningsBanner limits={limits} translate={translateLimits} eventSlug={slug} addons={addons} />
{eventAddons.length > 0 && (
<Card className="mb-6 border-0 bg-white/85 shadow-lg shadow-slate-100/50">
<CardHeader>
<CardTitle className="text-base font-semibold text-slate-900">{t('events.sections.addons.title', 'Add-ons & Upgrades')}</CardTitle>
<CardDescription>{t('events.sections.addons.description', 'Zusätzliche Kontingente für dieses Event.')}</CardDescription>
</CardHeader>
<CardContent>
<AddonSummaryList addons={eventAddons} t={(key, fallback) => t(key as any, fallback)} />
</CardContent>
</Card>
)}
<Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60"> <Card className="border-0 bg-white/85 shadow-xl shadow-sky-100/60">
<CardHeader> <CardHeader>
@@ -197,11 +236,49 @@ export default function EventPhotosPage() {
function LimitWarningsBanner({ function LimitWarningsBanner({
limits, limits,
translate, translate,
eventSlug,
addons,
}: { }: {
limits: EventLimitSummary | null; limits: EventLimitSummary | null;
translate: (key: string, options?: Record<string, unknown>) => string; translate: (key: string, options?: Record<string, unknown>) => string;
eventSlug: string | null;
addons: EventAddonCatalogItem[];
}) { }) {
const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]); const warnings = React.useMemo(() => buildLimitWarnings(limits, translate), [limits, translate]);
const [busyScope, setBusyScope] = React.useState<string | null>(null);
const handleCheckout = React.useCallback(
async (scopeOrKey: 'photos' | 'gallery' | string) => {
if (!eventSlug) return;
const scope = scopeOrKey === 'gallery' || scopeOrKey === 'photos' ? scopeOrKey : (scopeOrKey.includes('gallery') ? 'gallery' : 'photos');
setBusyScope(scope);
const addonKey = scopeOrKey === 'photos' || scopeOrKey === 'gallery'
? (() => {
const fallbackKey = scope === 'photos' ? 'extra_photos_500' : 'extend_gallery_30d';
const candidates = addons.filter((addon) => addon.price_id && addon.key.includes(scope === 'photos' ? 'photos' : 'gallery'));
return candidates[0]?.key ?? fallbackKey;
})()
: scopeOrKey;
try {
const currentUrl = window.location.origin + window.location.pathname;
const successUrl = `${currentUrl}?addon_success=1`;
const checkout = await createEventAddonCheckout(eventSlug, {
addon_key: addonKey,
quantity: 1,
success_url: successUrl,
cancel_url: currentUrl,
});
if (checkout.checkout_url) {
window.location.href = checkout.checkout_url;
}
} catch (err) {
toast(getApiErrorMessage(err, 'Checkout fehlgeschlagen.'));
} finally {
setBusyScope(null);
}
},
[eventSlug, addons],
);
if (!warnings.length) { if (!warnings.length) {
return null; return null;
@@ -215,10 +292,36 @@ function LimitWarningsBanner({
variant={warning.tone === 'danger' ? 'destructive' : 'default'} variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined} className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
> >
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<AlertDescription className="flex items-center gap-2 text-sm"> <AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
{warning.message} {warning.message}
</AlertDescription> </AlertDescription>
{warning.scope === 'photos' || warning.scope === 'gallery' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<Button
variant="outline"
size="sm"
onClick={() => { void handleCheckout(warning.scope as 'photos' | 'gallery'); }}
disabled={busyScope === warning.scope}
>
<ShoppingCart className="mr-2 h-4 w-4" />
{warning.scope === 'photos'
? translate('buyMorePhotos', { defaultValue: 'Mehr Fotos freischalten' })
: translate('extendGallery', { defaultValue: 'Galerie verlängern' })}
</Button>
<div className="text-xs text-slate-500">
<AddonsPicker
addons={addons}
scope={warning.scope as 'photos' | 'gallery'}
onCheckout={(key) => { void handleCheckout(key); }}
busy={busyScope === warning.scope}
t={(key, fallback) => translate(key, { defaultValue: fallback })}
/>
</div>
</div>
) : null}
</div>
</Alert> </Alert>
))} ))}
</div> </div>

View File

@@ -71,6 +71,8 @@ return [
'subject' => 'Foto-Uploads für „:event“ sind aktuell blockiert', 'subject' => 'Foto-Uploads für „:event“ sind aktuell blockiert',
'greeting' => 'Hallo :name,', 'greeting' => 'Hallo :name,',
'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Fotos erreicht. Gäste können keine neuen Fotos hochladen, bis Sie das Paket upgraden.', 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Fotos erreicht. Gäste können keine neuen Fotos hochladen, bis Sie das Paket upgraden.',
'cta_addon' => 'Brauchen Sie sofort mehr Uploads? Nutzen Sie das Add-on im Event-Dashboard, um zusätzliche Slots in Sekunden freizuschalten.',
'addon_action' => 'Mehr Fotos freischalten',
'action' => 'Paket verwalten oder upgraden', 'action' => 'Paket verwalten oder upgraden',
], ],
'guest_threshold' => [ 'guest_threshold' => [
@@ -83,6 +85,8 @@ return [
'subject' => 'Gästekontingent für „:event“ ist ausgeschöpft', 'subject' => 'Gästekontingent für „:event“ ist ausgeschöpft',
'greeting' => 'Hallo :name,', 'greeting' => 'Hallo :name,',
'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Gästen erreicht. Neue Gästelinks können erst nach einem Upgrade erstellt werden.', 'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Gästen erreicht. Neue Gästelinks können erst nach einem Upgrade erstellt werden.',
'cta_addon' => 'Benötigen Sie sofort mehr Gästeplätze? Nutzen Sie das Add-on im Event-Dashboard, um direkt neue Slots freizuschalten.',
'addon_action' => 'Mehr Gäste freischalten',
'action' => 'Paket verwalten oder upgraden', 'action' => 'Paket verwalten oder upgraden',
], ],
'event_threshold' => [ 'event_threshold' => [
@@ -129,4 +133,15 @@ return [
], ],
'footer' => 'Viele Grüße<br>Ihr Fotospiel-Team', 'footer' => 'Viele Grüße<br>Ihr Fotospiel-Team',
], ],
'addons' => [
'receipt' => [
'subject' => 'Add-on gekauft: :addon',
'greeting' => 'Hallo :name,',
'body' => 'Sie haben „ :addon “ für das Event „ :event “ gebucht. Betrag: :amount.',
'summary' => 'Enthalten: +:photos Fotos, +:guests Gäste, +:days Tage Galerie.',
'unknown_amount' => 'k.A.',
'action' => 'Event-Dashboard öffnen',
],
],
]; ];

View File

@@ -71,7 +71,9 @@ return [
'subject' => 'Photo uploads for ":event" are currently blocked', 'subject' => 'Photo uploads for ":event" are currently blocked',
'greeting' => 'Hello :name,', 'greeting' => 'Hello :name,',
'body' => 'The package ":package" for event ":event" has reached its maximum of :limit photos. Guests can no longer upload new photos until you upgrade the package.', 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit photos. Guests can no longer upload new photos until you upgrade the package.',
'cta_addon' => 'Need more uploads right now? Use the in-app add-on to unlock additional photo slots instantly.',
'action' => 'Upgrade or manage package', 'action' => 'Upgrade or manage package',
'addon_action' => 'Unlock more photos',
], ],
'guest_threshold' => [ 'guest_threshold' => [
'subject' => 'Event ":event" has used :percentage% of its guest allowance', 'subject' => 'Event ":event" has used :percentage% of its guest allowance',
@@ -83,7 +85,9 @@ return [
'subject' => 'Guest slots for ":event" are currently exhausted', 'subject' => 'Guest slots for ":event" are currently exhausted',
'greeting' => 'Hello :name,', 'greeting' => 'Hello :name,',
'body' => 'The package ":package" for event ":event" has reached its maximum of :limit guests. New guest invites cannot be created until you upgrade the package.', 'body' => 'The package ":package" for event ":event" has reached its maximum of :limit guests. New guest invites cannot be created until you upgrade the package.',
'cta_addon' => 'Need more guest access right away? Use the add-on button inside the event dashboard to unlock extra slots within seconds.',
'action' => 'Upgrade or manage package', 'action' => 'Upgrade or manage package',
'addon_action' => 'Unlock more guests',
], ],
'event_threshold' => [ 'event_threshold' => [
'subject' => 'Package ":package" has used :percentage% of its event allowance', 'subject' => 'Package ":package" has used :percentage% of its event allowance',
@@ -129,4 +133,15 @@ return [
], ],
'footer' => 'Best regards,<br>The Fotospiel Team', 'footer' => 'Best regards,<br>The Fotospiel Team',
], ],
'addons' => [
'receipt' => [
'subject' => 'Add-on purchase: :addon',
'greeting' => 'Hello :name,',
'body' => 'You purchased " :addon " for the event " :event ". Amount: :amount.',
'summary' => 'Included: +:photos photos, +:guests guests, +:days gallery days.',
'unknown_amount' => 'n/a',
'action' => 'Open event dashboard',
],
],
]; ];

View File

@@ -0,0 +1,44 @@
@php
/** @var \App\Models\EventPackageAddon $addon */
$event = $addon->event;
$tenant = $event?->tenant;
$label = $addon->metadata['label'] ?? $addon->addon_key;
$amount = $addon->amount ? number_format((float) $addon->amount, 2).' '.($addon->currency ?? 'EUR') : __('emails.addons.receipt.unknown_amount');
$summary = [];
if ($addon->extra_photos > 0) {
$summary[] = __('emails.addons.receipt.summary.photos', ['count' => number_format($addon->extra_photos)]);
}
if ($addon->extra_guests > 0) {
$summary[] = __('emails.addons.receipt.summary.guests', ['count' => number_format($addon->extra_guests)]);
}
if ($addon->extra_gallery_days > 0) {
$summary[] = __('emails.addons.receipt.summary.gallery', ['count' => $addon->extra_gallery_days]);
}
@endphp
@component('mail::message')
# {{ __('emails.addons.receipt.subject', ['addon' => $label]) }}
{{ __('emails.addons.receipt.greeting', ['name' => $tenant?->name ?? __('emails.package_limits.team_fallback')]) }}
{{ __('emails.addons.receipt.body', [
'addon' => $label,
'event' => $event?->name['de'] ?? $event?->name['en'] ?? $event?->name ?? __('emails.package_limits.event_fallback'),
'amount' => $amount,
]) }}
@if(!empty($summary))
**{{ __('emails.addons.receipt.summary_title', 'Included:') }}**
@foreach($summary as $line)
- {{ $line }}
@endforeach
@endif
@component('mail::button', ['url' => url('/tenant/events/'.($event?->slug ?? ''))])
{{ __('emails.addons.receipt.action') }}
@endcomponent
{{ __('emails.package_limits.footer') }}
@endcomponent

View File

@@ -7,6 +7,8 @@ use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController; use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\Tenant\DashboardController; use App\Http\Controllers\Api\Tenant\DashboardController;
use App\Http\Controllers\Api\Tenant\EmotionController; use App\Http\Controllers\Api\Tenant\EmotionController;
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
use App\Http\Controllers\Api\Tenant\EventAddonController;
use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventController;
use App\Http\Controllers\Api\Tenant\EventGuestNotificationController; use App\Http\Controllers\Api\Tenant\EventGuestNotificationController;
use App\Http\Controllers\Api\Tenant\EventJoinTokenController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
@@ -148,6 +150,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit'); Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index'); 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('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store');
Route::post('addons/apply', [EventAddonController::class, 'apply'])->name('tenant.events.addons.apply');
Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout');
}); });
Route::prefix('join-tokens')->group(function () { Route::prefix('join-tokens')->group(function () {
@@ -266,13 +270,25 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
}); });
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
->middleware('tenant.admin')
->name('tenant.addons.catalog');
Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () { Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () {
Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index'); Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index');
}); });
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) Route::prefix('billing')->middleware('tenant.admin')->group(function () {
->middleware('tenant.admin') Route::get('transactions', [TenantBillingController::class, 'transactions'])
->name('tenant.billing.transactions'); ->name('tenant.billing.transactions');
Route::get('addons', [TenantBillingController::class, 'addons'])
->name('tenant.billing.addons');
});
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
Route::get('transactions', [TenantBillingController::class, 'transactions']);
Route::get('addons', [TenantBillingController::class, 'addons']);
});
Route::post('feedback', [TenantFeedbackController::class, 'store']) Route::post('feedback', [TenantFeedbackController::class, 'store'])
->name('tenant.feedback.store'); ->name('tenant.feedback.store');

View File

@@ -0,0 +1,102 @@
<?php
namespace Tests\Feature\Api\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Models\Tenant;
use Tests\Feature\Tenant\TenantTestCase;
class BillingAddonHistoryTest extends TenantTestCase
{
public function test_tenant_can_list_addon_history(): void
{
$package = Package::factory()->endcustomer()->create([
'max_photos' => 500,
'max_guests' => 200,
'gallery_days' => 60,
]);
$event = Event::factory()->for($this->tenant)->create([
'name' => ['de' => 'Gala', 'en' => 'Gala'],
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subMonth(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
$firstAddon = EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_guests_50',
'quantity' => 1,
'extra_guests' => 50,
'status' => 'completed',
'amount' => 99.00,
'currency' => 'EUR',
'metadata' => ['label' => '+50 Gäste'],
'purchased_at' => now()->subDay(),
'receipt_payload' => ['receipt_url' => 'https://receipt.example/first'],
]);
$secondAddon = EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_photos_200',
'quantity' => 2,
'extra_photos' => 200,
'extra_guests' => 0,
'status' => 'pending',
'amount' => 149.00,
'currency' => 'EUR',
'metadata' => ['label' => '+200 Fotos'],
'purchased_at' => now(),
'receipt_payload' => ['receipt_url' => 'https://receipt.example/second'],
]);
$otherTenant = Tenant::factory()->create();
$otherEvent = Event::factory()->for($otherTenant)->create();
$otherPackage = EventPackage::create([
'event_id' => $otherEvent->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now()->subWeek(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
]);
EventPackageAddon::create([
'event_package_id' => $otherPackage->id,
'event_id' => $otherEvent->id,
'tenant_id' => $otherTenant->id,
'addon_key' => 'extra_guests_999',
'quantity' => 1,
'extra_guests' => 999,
'status' => 'completed',
'amount' => 100.00,
'currency' => 'EUR',
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', '/api/v1/tenant/billing/addons');
$response->assertOk();
$response->assertJsonCount(2, 'data');
$response->assertJsonPath('meta.total', 2);
$response->assertJsonPath('data.0.id', $secondAddon->id);
$response->assertJsonPath('data.0.receipt_url', 'https://receipt.example/second');
$response->assertJsonPath('data.0.event.slug', $event->slug);
$response->assertJsonPath('data.1.id', $firstAddon->id);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace Tests\Feature\Api\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Tests\Feature\Tenant\TenantTestCase;
class EventAddonsSummaryTest extends TenantTestCase
{
public function test_event_resource_includes_addons(): void
{
$package = Package::factory()->endcustomer()->create([
'max_guests' => 50,
'max_photos' => 100,
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(30),
'extra_guests' => 20,
]);
EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_guests_100',
'quantity' => 1,
'extra_guests' => 100,
'status' => 'completed',
'purchased_at' => now(),
]);
$response = $this->authenticatedRequest('GET', "/api/v1/tenant/events/{$event->slug}");
$response->assertOk();
$response->assertJsonPath('data.addons.0.key', 'extra_guests_100');
$response->assertJsonPath('data.addons.0.extra_guests', 100);
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Http;
class EventAddonCheckoutTest extends TenantTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('package-addons.extra_photos_small', [
'label' => 'Extra photos (500)',
'price_id' => 'pri_addon_photos',
'increments' => ['extra_photos' => 500],
]);
Config::set('paddle.api_key', 'test_key');
Config::set('paddle.base_url', 'https://paddle.test');
Config::set('paddle.environment', 'sandbox');
// Fake Paddle response
Http::fake([
'*/checkout/links' => Http::response([
'data' => [
'url' => 'https://checkout.paddle.test/abcd',
'id' => 'chk_addon_123',
'expires_at' => now()->addHour()->toIso8601String(),
],
], 200),
]);
}
public function test_checkout_creates_pending_addon_record(): void
{
$package = Package::factory()->endcustomer()->create([
'max_photos' => 100,
'max_guests' => 50,
'gallery_days' => 7,
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [
'addon_key' => 'extra_photos_small',
'quantity' => 2,
]);
$response->assertOk();
$response->assertJsonPath('checkout_id', 'chk_addon_123');
$this->assertDatabaseHas('event_package_addons', [
'event_package_id' => $eventPackage->id,
'addon_key' => 'extra_photos_small',
'status' => 'pending',
'quantity' => 2,
'checkout_id' => 'chk_addon_123',
]);
$addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first();
$this->assertSame(1000, $addon->extra_photos); // increments * quantity
}
}

View File

@@ -0,0 +1,78 @@
<?php
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
{
$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()->subDay(),
'used_photos' => 10,
'used_guests' => 5,
'gallery_expires_at' => Carbon::now()->addDays(7),
]);
$originalExpiry = $eventPackage->gallery_expires_at->copy();
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", [
'extra_photos' => 50,
'extra_guests' => 25,
'extend_gallery_days' => 3,
'reason' => 'Manual boost for event',
]);
$response->assertOk();
$response->assertJsonPath('data.limits.photos.limit', 150);
$response->assertJsonPath('data.limits.guests.limit', 75);
$eventPackage->refresh();
$this->assertSame(50, $eventPackage->extra_photos);
$this->assertSame(25, $eventPackage->extra_guests);
$this->assertSame(3, $eventPackage->extra_gallery_days);
$this->assertTrue($eventPackage->gallery_expires_at->isSameDay($originalExpiry->addDays(3)));
}
public function test_validation_fails_when_no_addons_provided(): void
{
$package = Package::factory()->endcustomer()->create();
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/apply", []);
$response->assertStatus(422);
$response->assertJsonValidationErrors('addons');
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use App\Notifications\Addons\AddonPurchaseReceipt;
use App\Services\Addons\EventAddonWebhookService;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Notification;
class EventAddonWebhookTest extends TenantTestCase
{
public function test_webhook_applies_addon_and_marks_completed(): void
{
Notification::fake();
Config::set('package-addons.extra_guests', [
'label' => 'Guests 100',
'increments' => ['extra_guests' => 100],
'price_id' => 'pri_guests',
]);
$package = Package::factory()->endcustomer()->create([
'max_guests' => 50,
]);
$event = Event::factory()->for($this->tenant)->create([
'status' => 'published',
]);
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 0,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
]);
$addon = EventPackageAddon::create([
'event_package_id' => $eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $this->tenant->id,
'addon_key' => 'extra_guests',
'quantity' => 1,
'extra_guests' => 100,
'status' => 'pending',
'metadata' => [
'addon_intent' => 'intent-123',
],
]);
$payload = [
'event_type' => 'transaction.completed',
'data' => [
'id' => 'txn_addon_1',
'metadata' => [
'addon_intent' => 'intent-123',
'addon_key' => 'extra_guests',
],
],
];
$handler = app(EventAddonWebhookService::class);
$handled = $handler->handle($payload);
$this->assertTrue($handled);
$addon->refresh();
$eventPackage->refresh();
$this->assertSame('completed', $addon->status);
$this->assertSame('txn_addon_1', $addon->transaction_id);
$this->assertSame(100, $eventPackage->extra_guests);
Notification::assertSentTimes(AddonPurchaseReceipt::class, 1);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace Tests\Unit\Jobs;
use App\Jobs\SyncPackageAddonToPaddle;
use App\Models\PackageAddon;
use App\Services\Paddle\PaddleAddonCatalogService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class SyncPackageAddonToPaddleTest extends TestCase
{
use RefreshDatabase;
public function test_creates_and_updates_price_and_product(): void
{
$addon = PackageAddon::create([
'key' => 'extra_photos_500',
'label' => '+500 Fotos',
'extra_photos' => 500,
'metadata' => ['price_eur' => 5],
]);
$service = Mockery::mock(PaddleAddonCatalogService::class);
$service->shouldReceive('createProduct')
->once()
->andReturn(['id' => 'pro_addon_1']);
$service->shouldReceive('createPrice')
->once()
->andReturn(['id' => 'pri_addon_1']);
$job = new SyncPackageAddonToPaddle($addon->id);
$job->handle($service);
$addon->refresh();
$this->assertSame('pri_addon_1', $addon->price_id);
$this->assertEquals('pro_addon_1', $addon->metadata['paddle_product_id']);
$this->assertEquals('synced', $addon->metadata['paddle_sync_status']);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace Tests\Unit\Services;
use App\Models\PackageAddon;
use App\Services\Addons\EventAddonCatalog;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
class EventAddonCatalogTest extends TestCase
{
use RefreshDatabase;
public function test_prefers_database_addons_over_config(): void
{
Config::set('package-addons', [
'extra_photos_small' => [
'label' => 'Config Photos',
'price_id' => 'pri_config',
'increments' => ['extra_photos' => 100],
],
]);
PackageAddon::create([
'key' => 'extra_photos_small',
'label' => 'DB Photos',
'price_id' => 'pri_db',
'extra_photos' => 200,
'active' => true,
'sort' => 1,
]);
$catalog = $this->app->make(EventAddonCatalog::class);
$addon = $catalog->find('extra_photos_small');
$this->assertNotNull($addon);
$this->assertSame('DB Photos', $addon['label']);
$this->assertSame('pri_db', $addon['price_id']);
$this->assertSame(200, $addon['increments']['extra_photos']);
}
}

View File

@@ -165,4 +165,40 @@ class PackageLimitEvaluatorTest extends TestCase
$this->assertTrue($summary['can_upload_photos']); $this->assertTrue($summary['can_upload_photos']);
$this->assertTrue($summary['can_add_guests']); $this->assertTrue($summary['can_add_guests']);
} }
public function test_assess_photo_upload_respects_extra_limits(): void
{
$package = Package::factory()->endcustomer()->create([
'max_photos' => 5,
]);
$tenant = Tenant::factory()->create();
$event = Event::factory()
->for($tenant)
->create();
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 5,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(14),
'extra_photos' => 5,
])->fresh(['package']);
$violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id);
$this->assertNull($violation, 'Upload should be allowed within extra photo allowance');
$eventPackage->update(['used_photos' => 10]);
$violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id);
$this->assertNotNull($violation, 'Upload should be blocked after exceeding base + extras');
$this->assertSame('photo_limit_exceeded', $violation['code']);
$this->assertSame(0, $violation['meta']['remaining']);
}
} }

View File

@@ -145,4 +145,41 @@ class PackageUsageTrackerTest extends TestCase
EventFacade::assertDispatched(EventPackageGuestLimitReached::class); EventFacade::assertDispatched(EventPackageGuestLimitReached::class);
} }
public function test_effective_limits_include_extras(): void
{
EventFacade::fake([
EventPackagePhotoLimitReached::class,
]);
$tenant = Tenant::factory()->create();
$package = Package::factory()->endcustomer()->create([
'max_photos' => 2,
]);
$event = Event::factory()->for($tenant)->create();
$eventPackage = EventPackage::create([
'event_id' => $event->id,
'package_id' => $package->id,
'purchased_price' => $package->price,
'purchased_at' => now(),
'used_photos' => 2,
'used_guests' => 0,
'gallery_expires_at' => now()->addDays(7),
'extra_photos' => 2,
])->fresh(['package']);
/** @var PackageUsageTracker $tracker */
$tracker = app(PackageUsageTracker::class);
// Base limit reached but extras still available; no limit event expected yet.
$tracker->recordPhotoUsage($eventPackage, 1, 1);
EventFacade::assertNotDispatched(EventPackagePhotoLimitReached::class);
// Now consume extras and hit the effective limit.
$eventPackage->used_photos = 4;
$tracker->recordPhotoUsage($eventPackage, 3, 1);
EventFacade::assertDispatched(EventPackagePhotoLimitReached::class);
}
} }