implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
147
app/Filament/Resources/PackageAddonResource.php
Normal file
147
app/Filament/Resources/PackageAddonResource.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/Api/Tenant/EventAddonController.php
Normal file
114
app/Http/Controllers/Api/Tenant/EventAddonController.php
Normal 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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -192,7 +192,7 @@ class EventController extends Controller
|
||||
});
|
||||
|
||||
$tenant->refresh();
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package']);
|
||||
$event->load(['eventType', 'tenant', 'eventPackages.package', 'eventPackages.addons']);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Event created successfully',
|
||||
@@ -222,7 +222,7 @@ class EventController extends Controller
|
||||
'tasks',
|
||||
'tenant' => fn ($query) => $query->select('id', 'name', 'event_credits_balance'),
|
||||
'eventPackages' => fn ($query) => $query
|
||||
->with('package')
|
||||
->with(['package', 'addons'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at'),
|
||||
]);
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Services\Paddle\PaddleTransactionService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TenantBillingController extends Controller
|
||||
@@ -60,4 +62,58 @@ class TenantBillingController extends Controller
|
||||
'meta' => $result['meta'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function addons(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->attributes->get('tenant');
|
||||
|
||||
if (! $tenant) {
|
||||
return response()->json([
|
||||
'data' => [],
|
||||
'message' => 'Tenant not found.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
$perPage = max(1, min((int) $request->query('per_page', 25), 100));
|
||||
$page = max(1, (int) $request->query('page', 1));
|
||||
|
||||
$paginator = EventPackageAddon::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->with(['event:id,name,slug'])
|
||||
->orderByDesc('purchased_at')
|
||||
->orderByDesc('created_at')
|
||||
->paginate($perPage, ['*'], 'page', $page);
|
||||
|
||||
$data = $paginator->getCollection()->map(function (EventPackageAddon $addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'addon_key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'quantity' => (int) ($addon->quantity ?? 1),
|
||||
'status' => $addon->status,
|
||||
'amount' => $addon->amount !== null ? (float) $addon->amount : null,
|
||||
'currency' => $addon->currency,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'receipt_url' => Arr::get($addon->receipt_payload, 'receipt_url'),
|
||||
'event' => $addon->event ? [
|
||||
'id' => $addon->event->id,
|
||||
'slug' => $addon->event->slug,
|
||||
'name' => $addon->event->name,
|
||||
] : null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
return response()->json([
|
||||
'data' => $data,
|
||||
'meta' => [
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'total' => $paginator->total(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Addons\EventAddonWebhookService;
|
||||
use App\Services\Checkout\CheckoutWebhookService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -10,7 +11,10 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class PaddleWebhookController extends Controller
|
||||
{
|
||||
public function __construct(private readonly CheckoutWebhookService $webhooks) {}
|
||||
public function __construct(
|
||||
private readonly CheckoutWebhookService $webhooks,
|
||||
private readonly EventAddonWebhookService $addonWebhooks,
|
||||
) {}
|
||||
|
||||
public function handle(Request $request): JsonResponse
|
||||
{
|
||||
@@ -31,6 +35,7 @@ class PaddleWebhookController extends Controller
|
||||
|
||||
if ($eventType) {
|
||||
$handled = $this->webhooks->handlePaddleEvent($payload);
|
||||
$handled = $this->addonWebhooks->handle($payload) || $handled;
|
||||
}
|
||||
|
||||
Log::info('Paddle webhook processed', [
|
||||
|
||||
32
app/Http/Requests/Tenant/EventAddonCheckoutRequest.php
Normal file
32
app/Http/Requests/Tenant/EventAddonCheckoutRequest.php
Normal 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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal file
40
app/Http/Requests/Tenant/EventAddonRequest.php
Normal 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.'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use App\Services\Packages\PackageLimitEvaluator;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Http\Resources\MissingValue;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventResource extends JsonResource
|
||||
{
|
||||
@@ -76,6 +77,34 @@ class EventResource extends JsonResource
|
||||
'limits' => $eventPackage && $limitEvaluator
|
||||
? $limitEvaluator->summarizeEventPackage($eventPackage)
|
||||
: null,
|
||||
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
|
||||
];
|
||||
}
|
||||
|
||||
protected function formatAddons(?\App\Models\EventPackage $eventPackage): array
|
||||
{
|
||||
if (! $eventPackage) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$addons = $eventPackage->relationLoaded('addons')
|
||||
? $eventPackage->addons
|
||||
: $eventPackage->addons()->latest()->take(10)->get();
|
||||
|
||||
return $addons->map(function ($addon) {
|
||||
return [
|
||||
'id' => $addon->id,
|
||||
'key' => $addon->addon_key,
|
||||
'label' => $addon->metadata['label'] ?? null,
|
||||
'status' => $addon->status,
|
||||
'price_id' => $addon->price_id,
|
||||
'transaction_id' => $addon->transaction_id,
|
||||
'extra_photos' => (int) $addon->extra_photos,
|
||||
'extra_guests' => (int) $addon->extra_guests,
|
||||
'extra_gallery_days' => (int) $addon->extra_gallery_days,
|
||||
'purchased_at' => $addon->purchased_at?->toIso8601String(),
|
||||
'metadata' => Arr::only($addon->metadata ?? [], ['price_eur']),
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
}
|
||||
|
||||
167
app/Jobs/SyncPackageAddonToPaddle.php
Normal file
167
app/Jobs/SyncPackageAddonToPaddle.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class EventPackage extends Model
|
||||
{
|
||||
@@ -20,6 +21,10 @@ class EventPackage extends Model
|
||||
'used_photos',
|
||||
'used_guests',
|
||||
'gallery_expires_at',
|
||||
'limits_snapshot',
|
||||
'extra_photos',
|
||||
'extra_guests',
|
||||
'extra_gallery_days',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -30,6 +35,10 @@ class EventPackage extends Model
|
||||
'gallery_expired_notified_at' => 'datetime',
|
||||
'used_photos' => 'integer',
|
||||
'used_guests' => 'integer',
|
||||
'extra_photos' => 'integer',
|
||||
'extra_guests' => 'integer',
|
||||
'extra_gallery_days' => 'integer',
|
||||
'limits_snapshot' => 'array',
|
||||
];
|
||||
|
||||
public function event(): BelongsTo
|
||||
@@ -42,6 +51,11 @@ class EventPackage extends Model
|
||||
return $this->belongsTo(Package::class)->withTrashed();
|
||||
}
|
||||
|
||||
public function addons(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventPackageAddon::class);
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->gallery_expires_at && $this->gallery_expires_at->isFuture();
|
||||
@@ -53,7 +67,11 @@ class EventPackage extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxPhotos = $this->package->max_photos ?? 0;
|
||||
$maxPhotos = $this->effectiveLimits()['max_photos'];
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->used_photos < $maxPhotos;
|
||||
}
|
||||
@@ -64,23 +82,84 @@ class EventPackage extends Model
|
||||
return false;
|
||||
}
|
||||
|
||||
$maxGuests = $this->package->max_guests ?? 0;
|
||||
$maxGuests = $this->effectiveLimits()['max_guests'];
|
||||
|
||||
if ($maxGuests === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->used_guests < $maxGuests;
|
||||
}
|
||||
|
||||
public function getRemainingPhotosAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_photos ?? 0;
|
||||
$limit = $this->effectiveLimits()['max_photos'] ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_photos);
|
||||
return max(0, (int) $limit - $this->used_photos);
|
||||
}
|
||||
|
||||
public function getRemainingGuestsAttribute(): int
|
||||
{
|
||||
$max = $this->package->max_guests ?? 0;
|
||||
$limit = $this->effectiveLimits()['max_guests'] ?? 0;
|
||||
|
||||
return max(0, $max - $this->used_guests);
|
||||
return max(0, (int) $limit - $this->used_guests);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{max_photos: ?int, max_guests: ?int, gallery_days: ?int, max_tasks: ?int, max_events_per_year: ?int}
|
||||
*/
|
||||
public function effectiveLimits(): array
|
||||
{
|
||||
$snapshot = is_array($this->limits_snapshot) ? $this->limits_snapshot : [];
|
||||
|
||||
$base = [
|
||||
'max_photos' => array_key_exists('max_photos', $snapshot)
|
||||
? $snapshot['max_photos']
|
||||
: ($this->package->max_photos ?? null),
|
||||
'max_guests' => array_key_exists('max_guests', $snapshot)
|
||||
? $snapshot['max_guests']
|
||||
: ($this->package->max_guests ?? null),
|
||||
'gallery_days' => array_key_exists('gallery_days', $snapshot)
|
||||
? $snapshot['gallery_days']
|
||||
: ($this->package->gallery_days ?? null),
|
||||
'max_tasks' => array_key_exists('max_tasks', $snapshot)
|
||||
? $snapshot['max_tasks']
|
||||
: ($this->package->max_tasks ?? null),
|
||||
'max_events_per_year' => array_key_exists('max_events_per_year', $snapshot)
|
||||
? $snapshot['max_events_per_year']
|
||||
: ($this->package->max_events_per_year ?? null),
|
||||
];
|
||||
|
||||
$applyExtra = static function (?int $limit, int $extra): ?int {
|
||||
if ($limit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$safeExtra = max(0, $extra);
|
||||
|
||||
return max(0, $limit + $safeExtra);
|
||||
};
|
||||
|
||||
$maxPhotos = $applyExtra($this->normalizeLimit($base['max_photos']), (int) ($this->extra_photos ?? 0));
|
||||
$maxGuests = $applyExtra($this->normalizeLimit($base['max_guests']), (int) ($this->extra_guests ?? 0));
|
||||
|
||||
return [
|
||||
'max_photos' => $maxPhotos,
|
||||
'max_guests' => $maxGuests,
|
||||
'gallery_days' => $this->normalizeLimit($base['gallery_days']),
|
||||
'max_tasks' => $this->normalizeLimit($base['max_tasks']),
|
||||
'max_events_per_year' => $this->normalizeLimit($base['max_events_per_year']),
|
||||
];
|
||||
}
|
||||
|
||||
public function effectivePhotoLimit(): ?int
|
||||
{
|
||||
return $this->effectiveLimits()['max_photos'];
|
||||
}
|
||||
|
||||
public function effectiveGuestLimit(): ?int
|
||||
{
|
||||
return $this->effectiveLimits()['max_guests'];
|
||||
}
|
||||
|
||||
protected static function boot()
|
||||
@@ -95,6 +174,31 @@ class EventPackage extends Model
|
||||
$days = $eventPackage->package->gallery_days ?? 30;
|
||||
$eventPackage->gallery_expires_at = now()->addDays($days);
|
||||
}
|
||||
|
||||
if (! $eventPackage->limits_snapshot) {
|
||||
$package = $eventPackage->relationLoaded('package')
|
||||
? $eventPackage->package
|
||||
: Package::query()->find($eventPackage->package_id);
|
||||
|
||||
if ($package) {
|
||||
$eventPackage->limits_snapshot = array_filter([
|
||||
'max_photos' => $package->max_photos,
|
||||
'max_guests' => $package->max_guests,
|
||||
'gallery_days' => $package->gallery_days,
|
||||
'max_tasks' => $package->max_tasks,
|
||||
'max_events_per_year' => $package->max_events_per_year,
|
||||
], static fn ($value) => $value !== null);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function normalizeLimit($value): ?int
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return is_numeric($value) ? (int) $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
66
app/Models/EventPackageAddon.php
Normal file
66
app/Models/EventPackageAddon.php
Normal 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);
|
||||
}
|
||||
}
|
||||
44
app/Models/PackageAddon.php
Normal file
44
app/Models/PackageAddon.php
Normal 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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
47
app/Notifications/Addons/AddonPurchaseReceipt.php
Normal file
47
app/Notifications/Addons/AddonPurchaseReceipt.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,8 @@ class EventPackageGuestLimitNotification extends Notification implements ShouldQ
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->line(__('emails.package_limits.guest_limit.cta_addon'))
|
||||
->action(__('emails.package_limits.guest_limit.addon_action'), $url)
|
||||
->action(__('emails.package_limits.guest_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ class EventPackagePhotoLimitNotification extends Notification implements ShouldQ
|
||||
'package' => $package?->getNameForLocale() ?? $package?->name ?? __('emails.package_limits.package_fallback'),
|
||||
'limit' => $this->limit,
|
||||
]))
|
||||
->line(__('emails.package_limits.photo_limit.cta_addon'))
|
||||
->action(__('emails.package_limits.photo_limit.addon_action'), $url)
|
||||
->action(__('emails.package_limits.photo_limit.action'), $url)
|
||||
->line(__('emails.package_limits.footer'));
|
||||
}
|
||||
|
||||
63
app/Services/Addons/EventAddonCatalog.php
Normal file
63
app/Services/Addons/EventAddonCatalog.php
Normal 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();
|
||||
}
|
||||
}
|
||||
112
app/Services/Addons/EventAddonCheckoutService.php
Normal file
112
app/Services/Addons/EventAddonCheckoutService.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
125
app/Services/Addons/EventAddonWebhookService.php
Normal file
125
app/Services/Addons/EventAddonWebhookService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class PackageLimitEvaluator
|
||||
];
|
||||
}
|
||||
|
||||
$maxPhotos = $eventPackage->package->max_photos;
|
||||
$maxPhotos = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return null;
|
||||
@@ -115,17 +115,17 @@ class PackageLimitEvaluator
|
||||
|
||||
public function summarizeEventPackage(EventPackage $eventPackage): array
|
||||
{
|
||||
$package = $eventPackage->package;
|
||||
$limits = $eventPackage->effectiveLimits();
|
||||
|
||||
$photoSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_photos,
|
||||
$package?->max_photos,
|
||||
$limits['max_photos'],
|
||||
config('package-limits.photo_thresholds', [])
|
||||
);
|
||||
|
||||
$guestSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$package?->max_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
@@ -51,7 +51,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_guests;
|
||||
$limit = $eventPackage->effectiveGuestLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
|
||||
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal file
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,13 @@ class PhotoboothIngestService
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = $eventPackage->package->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
return $limit !== null
|
||||
&& $limit > 0
|
||||
&& $eventPackage->used_photos >= $limit;
|
||||
if ($limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $limit > 0 && $eventPackage->used_photos >= $limit;
|
||||
}
|
||||
|
||||
protected function resolveEmotionId(Event $event): ?int
|
||||
|
||||
@@ -29,7 +29,9 @@ class Mailbox
|
||||
'html' => method_exists($event->message, 'getHtmlBody') ? $event->message->getHtmlBody() : null,
|
||||
'text' => method_exists($event->message, 'getTextBody') ? $event->message->getTextBody() : null,
|
||||
'sent_at' => now()->toIso8601String(),
|
||||
'headers' => (string) $event->message->getHeaders(),
|
||||
'headers' => method_exists($event->message, 'getHeaders') && method_exists($event->message->getHeaders(), 'toString')
|
||||
? $event->message->getHeaders()->toString()
|
||||
: null,
|
||||
];
|
||||
|
||||
self::write($messages);
|
||||
|
||||
33
config/package-addons.php
Normal file
33
config/package-addons.php
Normal 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,
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -16,6 +16,7 @@ class DatabaseSeeder extends Seeder
|
||||
MediaStorageTargetSeeder::class,
|
||||
LegalPagesSeeder::class,
|
||||
PackageSeeder::class,
|
||||
PackageAddonSeeder::class,
|
||||
EventTypesSeeder::class,
|
||||
EmotionsSeeder::class,
|
||||
TaskCollectionsSeeder::class,
|
||||
|
||||
143
database/seeders/PackageAddonSeeder.php
Normal file
143
database/seeders/PackageAddonSeeder.php
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export type TenantEvent = {
|
||||
expires_at: string | null;
|
||||
} | null;
|
||||
limits?: EventLimitSummary | null;
|
||||
addons?: EventAddonSummary[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
@@ -156,6 +157,32 @@ export type PhotoboothStatus = {
|
||||
};
|
||||
};
|
||||
|
||||
export type EventAddonCheckout = {
|
||||
addon_key: string;
|
||||
quantity?: number;
|
||||
checkout_url: string | null;
|
||||
checkout_id: string | null;
|
||||
expires_at: string | null;
|
||||
};
|
||||
|
||||
export type EventAddonCatalogItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
price_id: string | null;
|
||||
increments?: Record<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 = {
|
||||
slug: string;
|
||||
title: string;
|
||||
@@ -338,6 +365,28 @@ export type PaddleTransactionSummary = {
|
||||
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 = {
|
||||
id: number;
|
||||
delta: number;
|
||||
@@ -829,6 +878,48 @@ function normalizePaddleTransaction(entry: JsonValue): PaddleTransactionSummary
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTenantAddonHistoryEntry(entry: JsonValue): TenantAddonHistoryEntry {
|
||||
let event: TenantAddonEventSummary | null = null;
|
||||
|
||||
if (entry.event && typeof entry.event === 'object') {
|
||||
const rawEvent = entry.event as JsonValue;
|
||||
const id = Number((rawEvent as { id?: unknown }).id ?? 0);
|
||||
const slugValue = (rawEvent as { slug?: unknown }).slug;
|
||||
const rawName = (rawEvent as { name?: unknown }).name ?? null;
|
||||
let name: TenantAddonEventSummary['name'] = null;
|
||||
|
||||
if (typeof rawName === 'string') {
|
||||
name = rawName;
|
||||
} else if (rawName && typeof rawName === 'object') {
|
||||
name = normalizeTranslationMap(rawName, undefined, true);
|
||||
}
|
||||
|
||||
event = {
|
||||
id,
|
||||
slug: typeof slugValue === 'string' ? slugValue : '',
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
const amountValue = entry.amount;
|
||||
|
||||
return {
|
||||
id: Number(entry.id ?? 0),
|
||||
addon_key: String(entry.addon_key ?? ''),
|
||||
label: typeof entry.label === 'string' ? entry.label : null,
|
||||
event,
|
||||
amount: amountValue !== undefined && amountValue !== null ? Number(amountValue) : null,
|
||||
currency: typeof entry.currency === 'string' ? entry.currency : null,
|
||||
status: (entry.status as TenantAddonHistoryEntry['status']) ?? 'pending',
|
||||
purchased_at: typeof entry.purchased_at === 'string' ? entry.purchased_at : null,
|
||||
extra_photos: Number(entry.extra_photos ?? 0),
|
||||
extra_guests: Number(entry.extra_guests ?? 0),
|
||||
extra_gallery_days: Number(entry.extra_gallery_days ?? 0),
|
||||
quantity: Number(entry.quantity ?? 1),
|
||||
receipt_url: typeof entry.receipt_url === 'string' ? entry.receipt_url : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTask(task: JsonValue): TenantTask {
|
||||
const titleTranslations = normalizeTranslationMap(task.title_translations ?? task.title ?? {});
|
||||
const descriptionTranslations = normalizeTranslationMap(task.description_translations ?? task.description ?? {});
|
||||
@@ -1122,6 +1213,28 @@ export async function getEvent(slug: string): Promise<TenantEvent> {
|
||||
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[]> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/event-types');
|
||||
const data = await jsonOrThrow<{ data?: JsonValue[] }>(response, 'Failed to load event types');
|
||||
@@ -1675,6 +1788,42 @@ export async function getTenantPaddleTransactions(cursor?: string): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTenantAddonHistory(page = 1, perPage = 25): Promise<{
|
||||
data: TenantAddonHistoryEntry[];
|
||||
meta: PaginationMeta;
|
||||
}> {
|
||||
const params = new URLSearchParams({
|
||||
page: String(Math.max(1, page)),
|
||||
per_page: String(Math.max(1, Math.min(perPage, 100))),
|
||||
});
|
||||
|
||||
const response = await authorizedFetch(`/api/v1/tenant/billing/addons?${params.toString()}`);
|
||||
|
||||
if (response.status === 404) {
|
||||
return {
|
||||
data: [],
|
||||
meta: { current_page: 1, last_page: 1, per_page: perPage, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await jsonOrThrow<{ data?: JsonValue[]; meta?: Partial<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> {
|
||||
const response = await authorizedFetch('/api/v1/tenant/credits/balance');
|
||||
if (response.status === 404) {
|
||||
|
||||
66
resources/js/admin/components/Addons/AddonSummaryList.tsx
Normal file
66
resources/js/admin/components/Addons/AddonSummaryList.tsx
Normal 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(' · ');
|
||||
}
|
||||
64
resources/js/admin/components/Addons/AddonsPicker.tsx
Normal file
64
resources/js/admin/components/Addons/AddonsPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -72,6 +72,9 @@
|
||||
"galleryWarningDay": "Galerie läuft in {days} Tag ab.",
|
||||
"galleryWarningDays": "Galerie läuft in {days} Tagen ab.",
|
||||
"galleryExpired": "Galerie ist abgelaufen. Gäste sehen keine Inhalte mehr.",
|
||||
"unlimited": "Unbegrenzt"
|
||||
"unlimited": "Unbegrenzt",
|
||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten",
|
||||
"extendGallery": "Galerie verlängern"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,32 @@
|
||||
"loadingMore": "Laden…"
|
||||
}
|
||||
},
|
||||
"addOns": {
|
||||
"title": "Add-on-Verlauf",
|
||||
"description": "Einmalige Add-ons, die für diesen Tenant gebucht wurden.",
|
||||
"empty": "Noch keine Add-ons gebucht.",
|
||||
"badge": "Add-ons",
|
||||
"table": {
|
||||
"addon": "Add-on",
|
||||
"event": "Event",
|
||||
"amount": "Betrag",
|
||||
"status": "Status",
|
||||
"purchased": "Gekauft",
|
||||
"eventFallback": "Event archiviert"
|
||||
},
|
||||
"status": {
|
||||
"pending": "In Bearbeitung",
|
||||
"completed": "Abgeschlossen",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"extras": {
|
||||
"photos": "+{{count}} Fotos",
|
||||
"guests": "+{{count}} Gäste",
|
||||
"gallery": "+{{count}} Galerietage"
|
||||
},
|
||||
"loadMore": "Weitere Add-ons laden",
|
||||
"loadingMore": "Add-ons werden geladen…"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Paket-Historie",
|
||||
"description": "Übersicht über aktive und vergangene Pakete.",
|
||||
@@ -382,7 +408,8 @@
|
||||
"backToEvent": "Event öffnen",
|
||||
"copy": "Link kopieren",
|
||||
"copied": "Kopiert!",
|
||||
"deactivate": "Deaktivieren"
|
||||
"deactivate": "Deaktivieren",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Nutzung",
|
||||
@@ -511,11 +538,16 @@
|
||||
"loadFailed": "Event konnte nicht geladen werden.",
|
||||
"notFoundTitle": "Event nicht gefunden",
|
||||
"notFoundBody": "Ohne gültige Kennung können wir keine Daten laden. Kehre zur Eventliste zurück und wähle dort ein Event aus.",
|
||||
"toggleFailed": "Status konnte nicht angepasst werden."
|
||||
"toggleFailed": "Status konnte nicht angepasst werden.",
|
||||
"checkoutMissing": "Checkout konnte nicht gestartet werden.",
|
||||
"checkoutFailed": "Add-on Checkout fehlgeschlagen."
|
||||
},
|
||||
"alerts": {
|
||||
"failedTitle": "Aktion fehlgeschlagen"
|
||||
},
|
||||
"success": {
|
||||
"addonApplied": "Add-on angewendet. Limits aktualisieren sich in Kürze."
|
||||
},
|
||||
"placeholders": {
|
||||
"untitled": "Unbenanntes Event"
|
||||
},
|
||||
@@ -526,7 +558,10 @@
|
||||
"tasks": "Aufgaben verwalten",
|
||||
"invites": "Einladungen & Layouts",
|
||||
"photos": "Fotos moderieren",
|
||||
"refresh": "Aktualisieren"
|
||||
"refresh": "Aktualisieren",
|
||||
"buyMorePhotos": "Mehr Fotos freischalten",
|
||||
"buyMoreGuests": "Mehr Gäste freischalten",
|
||||
"extendGallery": "Galerie verlängern"
|
||||
},
|
||||
"workspace": {
|
||||
"detailSubtitle": "Behalte Status, Aufgaben und Einladungen deines Events im Blick.",
|
||||
@@ -552,6 +587,23 @@
|
||||
"activeYes": "Ja",
|
||||
"activeNo": "Nein"
|
||||
},
|
||||
"sections": {
|
||||
"addons": {
|
||||
"title": "Add-ons & Upgrades",
|
||||
"description": "Zuletzt gebuchte Add-ons für dieses Event.",
|
||||
"status": {
|
||||
"completed": "Aktiv",
|
||||
"pending": "In Bearbeitung",
|
||||
"failed": "Fehlgeschlagen"
|
||||
},
|
||||
"purchasedAt": "Gekauft {{date}}",
|
||||
"summary": {
|
||||
"photos": "+{{count}} Fotos",
|
||||
"guests": "+{{count}} Gäste",
|
||||
"gallery": "+{{count}} Tage Galerie"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"published": "Veröffentlicht",
|
||||
"draft": "Entwurf",
|
||||
|
||||
@@ -72,6 +72,9 @@
|
||||
"galleryWarningDay": "Gallery expires in {days} day.",
|
||||
"galleryWarningDays": "Gallery expires in {days} days.",
|
||||
"galleryExpired": "Gallery has expired. Guests can no longer access the photos.",
|
||||
"unlimited": "Unlimited"
|
||||
"unlimited": "Unlimited",
|
||||
"buyMorePhotos": "Unlock more photos",
|
||||
"buyMoreGuests": "Unlock more guests",
|
||||
"extendGallery": "Extend gallery"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,32 @@
|
||||
"loadingMore": "Loading…"
|
||||
}
|
||||
},
|
||||
"addOns": {
|
||||
"title": "Add-on history",
|
||||
"description": "One-time add-ons purchased for this tenant.",
|
||||
"empty": "No add-ons purchased yet.",
|
||||
"badge": "Add-ons",
|
||||
"table": {
|
||||
"addon": "Add-on",
|
||||
"event": "Event",
|
||||
"amount": "Amount",
|
||||
"status": "Status",
|
||||
"purchased": "Purchased",
|
||||
"eventFallback": "Event archived"
|
||||
},
|
||||
"status": {
|
||||
"pending": "Processing",
|
||||
"completed": "Completed",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"extras": {
|
||||
"photos": "+{{count}} photos",
|
||||
"guests": "+{{count}} guests",
|
||||
"gallery": "+{{count}} gallery days"
|
||||
},
|
||||
"loadMore": "Load more add-ons",
|
||||
"loadingMore": "Loading add-ons…"
|
||||
},
|
||||
"packages": {
|
||||
"title": "Package history",
|
||||
"description": "Overview of current and past packages.",
|
||||
@@ -382,7 +408,8 @@
|
||||
"backToEvent": "Open event",
|
||||
"copy": "Copy link",
|
||||
"copied": "Copied!",
|
||||
"deactivate": "Deactivate"
|
||||
"deactivate": "Deactivate",
|
||||
"buyMoreGuests": "Unlock more guests"
|
||||
},
|
||||
"labels": {
|
||||
"usage": "Usage",
|
||||
@@ -511,11 +538,16 @@
|
||||
"loadFailed": "Event could not be loaded.",
|
||||
"notFoundTitle": "Event not found",
|
||||
"notFoundBody": "Without a valid identifier we can’t load the data. Return to the list and choose an event.",
|
||||
"toggleFailed": "Status could not be updated."
|
||||
"toggleFailed": "Status could not be updated.",
|
||||
"checkoutMissing": "Checkout could not be started.",
|
||||
"checkoutFailed": "Add-on checkout failed."
|
||||
},
|
||||
"alerts": {
|
||||
"failedTitle": "Action failed"
|
||||
},
|
||||
"success": {
|
||||
"addonApplied": "Add-on applied. Limits will refresh shortly."
|
||||
},
|
||||
"placeholders": {
|
||||
"untitled": "Untitled event"
|
||||
},
|
||||
@@ -526,7 +558,10 @@
|
||||
"tasks": "Manage tasks",
|
||||
"invites": "Invites & layouts",
|
||||
"photos": "Moderate photos",
|
||||
"refresh": "Refresh"
|
||||
"refresh": "Refresh",
|
||||
"buyMorePhotos": "Unlock more photos",
|
||||
"buyMoreGuests": "Unlock more guests",
|
||||
"extendGallery": "Extend gallery"
|
||||
},
|
||||
"workspace": {
|
||||
"detailSubtitle": "Keep status, tasks, and invites of your event in one view.",
|
||||
@@ -552,6 +587,23 @@
|
||||
"activeYes": "Yes",
|
||||
"activeNo": "No"
|
||||
},
|
||||
"sections": {
|
||||
"addons": {
|
||||
"title": "Add-ons & Boosts",
|
||||
"description": "Recently purchased add-ons for this event.",
|
||||
"status": {
|
||||
"completed": "Active",
|
||||
"pending": "Processing",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"purchasedAt": "Purchased {{date}}",
|
||||
"summary": {
|
||||
"photos": "+{{count}} photos",
|
||||
"guests": "+{{count}} guests",
|
||||
"gallery": "+{{count}} days gallery"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": {
|
||||
"published": "Published",
|
||||
"draft": "Draft",
|
||||
|
||||
@@ -8,7 +8,15 @@ import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { getTenantPackagesOverview, getTenantPaddleTransactions, PaddleTransactionSummary, TenantPackageSummary } from '../api';
|
||||
import {
|
||||
getTenantPackagesOverview,
|
||||
getTenantPaddleTransactions,
|
||||
getTenantAddonHistory,
|
||||
PaddleTransactionSummary,
|
||||
TenantAddonHistoryEntry,
|
||||
TenantPackageSummary,
|
||||
PaginationMeta,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
TenantHeroCard,
|
||||
@@ -34,6 +42,9 @@ export default function BillingPage() {
|
||||
const [transactionCursor, setTransactionCursor] = React.useState<string | null>(null);
|
||||
const [transactionsHasMore, setTransactionsHasMore] = 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 [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
@@ -55,6 +66,33 @@ export default function BillingPage() {
|
||||
[locale]
|
||||
);
|
||||
|
||||
const resolveEventName = React.useCallback(
|
||||
(event: TenantAddonHistoryEntry['event']) => {
|
||||
const fallback = t('billing.sections.addOns.table.eventFallback', 'Event removed');
|
||||
if (!event) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof event.name === 'string' && event.name.trim().length > 0) {
|
||||
return event.name;
|
||||
}
|
||||
|
||||
if (event.name && typeof event.name === 'object') {
|
||||
const lang = i18n.language?.split('-')[0] ?? 'de';
|
||||
return (
|
||||
event.name[lang] ??
|
||||
event.name.de ??
|
||||
event.name.en ??
|
||||
Object.values(event.name)[0] ??
|
||||
fallback
|
||||
);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
},
|
||||
[i18n.language, t]
|
||||
);
|
||||
|
||||
const packageLabels = React.useMemo(
|
||||
() => ({
|
||||
statusActive: t('billing.sections.packages.card.statusActive'),
|
||||
@@ -70,18 +108,24 @@ export default function BillingPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [packagesResult, paddleTransactions] = await Promise.all([
|
||||
const [packagesResult, paddleTransactions, addonHistoryResult] = await Promise.all([
|
||||
getTenantPackagesOverview(force ? { force: true } : undefined),
|
||||
getTenantPaddleTransactions().catch((err) => {
|
||||
console.warn('Failed to load Paddle transactions', err);
|
||||
return { data: [] as PaddleTransactionSummary[], nextCursor: null, hasMore: false };
|
||||
}),
|
||||
getTenantAddonHistory().catch((err) => {
|
||||
console.warn('Failed to load add-on history', err);
|
||||
return { data: [] as TenantAddonHistoryEntry[], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } };
|
||||
}),
|
||||
]);
|
||||
setPackages(packagesResult.packages);
|
||||
setActivePackage(packagesResult.activePackage);
|
||||
setTransactions(paddleTransactions.data);
|
||||
setTransactionCursor(paddleTransactions.nextCursor);
|
||||
setTransactionsHasMore(paddleTransactions.hasMore);
|
||||
setAddonHistory(addonHistoryResult.data);
|
||||
setAddonMeta(addonHistoryResult.meta);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(t('billing.errors.load'));
|
||||
@@ -110,6 +154,24 @@ export default function BillingPage() {
|
||||
}
|
||||
}, [transactionCursor, transactionsHasMore, transactionsLoading]);
|
||||
|
||||
const loadMoreAddons = React.useCallback(async () => {
|
||||
if (addonsLoading || !addonMeta || addonMeta.current_page >= addonMeta.last_page) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAddonsLoading(true);
|
||||
try {
|
||||
const nextPage = addonMeta.current_page + 1;
|
||||
const result = await getTenantAddonHistory(nextPage);
|
||||
setAddonHistory((current) => [...current, ...result.data]);
|
||||
setAddonMeta(result.meta);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load additional add-on history', error);
|
||||
} finally {
|
||||
setAddonsLoading(false);
|
||||
}
|
||||
}, [addonMeta, addonsLoading]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadAll();
|
||||
}, [loadAll]);
|
||||
@@ -118,6 +180,12 @@ export default function BillingPage() {
|
||||
() => buildPackageWarnings(activePackage, t, formatDate, 'billing.sections.overview.warnings'),
|
||||
[activePackage, t, formatDate],
|
||||
);
|
||||
const hasMoreAddons = React.useMemo(() => {
|
||||
if (!addonMeta) {
|
||||
return false;
|
||||
}
|
||||
return addonMeta.current_page < addonMeta.last_page;
|
||||
}, [addonMeta]);
|
||||
|
||||
const heroBadge = t('billing.hero.badge', 'Abrechnung');
|
||||
const heroDescription = t('billing.hero.description', 'Behalte Laufzeiten, Rechnungen und Limits deiner Pakete im Blick.');
|
||||
@@ -288,6 +356,38 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</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">
|
||||
<SectionHeader
|
||||
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({
|
||||
transaction,
|
||||
formatCurrency,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertTriangle,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
RefreshCw,
|
||||
Smile,
|
||||
Sparkles,
|
||||
ShoppingCart,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
toggleEvent,
|
||||
submitTenantFeedback,
|
||||
updatePhotoVisibility,
|
||||
createEventAddonCheckout,
|
||||
} from '../api';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { getApiErrorMessage } from '../lib/apiError';
|
||||
@@ -54,6 +56,9 @@ import {
|
||||
ActionGrid,
|
||||
TenantHeroCard,
|
||||
} from '../components/tenant';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { EventAddonCatalogItem, getAddonCatalog } from '../api';
|
||||
import { GuestBroadcastCard } from '../components/GuestBroadcastCard';
|
||||
|
||||
type EventDetailPageProps = {
|
||||
@@ -76,6 +81,7 @@ type WorkspaceState = {
|
||||
|
||||
export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProps) {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('management');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
@@ -91,6 +97,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
|
||||
const [toolkit, setToolkit] = React.useState<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 () => {
|
||||
if (!slug) {
|
||||
@@ -103,8 +112,9 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
setToolkit((prev) => ({ ...prev, loading: true, error: null }));
|
||||
|
||||
try {
|
||||
const [eventData, statsData] = await Promise.all([getEvent(slug), getEventStats(slug)]);
|
||||
const [eventData, statsData, addonOptions] = await Promise.all([getEvent(slug), getEventStats(slug), getAddonCatalog()]);
|
||||
setState((prev) => ({ ...prev, event: eventData, stats: statsData, loading: false }));
|
||||
setAddonsCatalog(addonOptions);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState((prev) => ({
|
||||
@@ -181,7 +191,43 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
[event?.limits, tCommon],
|
||||
);
|
||||
|
||||
const shownWarningToasts = React.useRef<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(() => {
|
||||
limitWarnings.forEach((warning) => {
|
||||
@@ -198,6 +244,30 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
});
|
||||
}, [limitWarnings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
void load();
|
||||
setAddonRefreshCount(3);
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.delete('addon_success');
|
||||
const search = params.toString();
|
||||
navigate(search ? `${window.location.pathname}?${search}` : window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (addonRefreshCount <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
void load();
|
||||
setAddonRefreshCount((count) => count - 1);
|
||||
}, 8000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [addonRefreshCount, load]);
|
||||
|
||||
if (!slug) {
|
||||
return (
|
||||
<AdminLayout
|
||||
@@ -230,10 +300,39 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
@@ -259,6 +358,16 @@ export default function EventDetailPage({ mode = 'detail' }: EventDetailPageProp
|
||||
|
||||
{(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)]">
|
||||
<StatusCard event={event} stats={stats} busy={busy} onToggle={handleToggle} />
|
||||
<QuickActionsCard
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, Copy, Download, Loader2, Printer, QrCode, RefreshCw, Share2, X, ShoppingCart } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import {
|
||||
@@ -20,6 +21,9 @@ import {
|
||||
TenantEvent,
|
||||
updateEventQrInvite,
|
||||
EventQrInviteLayout,
|
||||
createEventAddonCheckout,
|
||||
getAddonCatalog,
|
||||
type EventAddonCatalogItem,
|
||||
} from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import {
|
||||
@@ -29,6 +33,8 @@ import {
|
||||
ADMIN_EVENT_PHOTOS_PATH,
|
||||
} from '../constants';
|
||||
import { buildLimitWarnings } from '../lib/limitWarnings';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { InviteLayoutCustomizerPanel, QrLayoutCustomization } from './components/InviteLayoutCustomizerPanel';
|
||||
import { buildDownloadFilename, normalizeEventDateSegment } from './components/invite-layout/fileNames';
|
||||
import { DesignerCanvas } from './components/invite-layout/DesignerCanvas';
|
||||
@@ -191,9 +197,14 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
|
||||
setState((prev) => ({ ...prev, loading: true, error: null }));
|
||||
try {
|
||||
const [eventData, invitesData] = await Promise.all([getEvent(slug), getEventQrInvites(slug)]);
|
||||
const [eventData, invitesData, catalog] = await Promise.all([
|
||||
getEvent(slug),
|
||||
getEventQrInvites(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setState({ event: eventData, invites: invitesData, loading: false, error: null });
|
||||
setSelectedInviteId((current) => current ?? invitesData[0]?.id ?? null);
|
||||
setAddonsCatalog(catalog);
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
setState({ event: null, invites: [], loading: false, error: 'QR-Einladungen konnten nicht geladen werden.' });
|
||||
@@ -765,6 +776,36 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
[state.event?.limits, tLimits]
|
||||
);
|
||||
|
||||
const [addonBusy, setAddonBusy] = React.useState<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(
|
||||
() => ({
|
||||
photos: tLimits('photosTitle'),
|
||||
@@ -774,6 +815,16 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
[tLimits]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(t('events.success.addonApplied', 'Add-on angewendet. Limits aktualisieren sich in Kürze.'));
|
||||
void load();
|
||||
searchParams.delete('addon_success');
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, t]);
|
||||
|
||||
return (
|
||||
<AdminLayout
|
||||
title={eventName}
|
||||
@@ -788,6 +839,8 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{limitScopeLabels[warning.scope]}
|
||||
@@ -795,11 +848,45 @@ export default function EventInvitesPage(): React.ReactElement {
|
||||
<AlertDescription className="text-sm">
|
||||
{warning.message}
|
||||
</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>
|
||||
))}
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Camera, Loader2, Sparkles, Trash2, AlertTriangle, ShoppingCart } from 'lucide-react';
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import toast from 'react-hot-toast';
|
||||
import { AddonsPicker } from '../components/Addons/AddonsPicker';
|
||||
import { AddonSummaryList } from '../components/Addons/AddonSummaryList';
|
||||
import { getAddonCatalog, getEvent, type EventAddonCatalogItem, type EventAddonSummary } from '../api';
|
||||
|
||||
import { AdminLayout } from '../components/AdminLayout';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { createEventAddonCheckout, deletePhoto, featurePhoto, getEventPhotos, TenantPhoto, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { getApiErrorMessage, isApiError } from '../lib/apiError';
|
||||
import { buildLimitWarnings, type EventLimitSummary } from '../lib/limitWarnings';
|
||||
@@ -31,6 +35,10 @@ export default function EventPhotosPage() {
|
||||
const [error, setError] = React.useState<string | undefined>(undefined);
|
||||
const [busyId, setBusyId] = React.useState<number | 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 () => {
|
||||
if (!slug) {
|
||||
@@ -40,9 +48,16 @@ export default function EventPhotosPage() {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const result = await getEventPhotos(slug);
|
||||
setPhotos(result.photos);
|
||||
setLimits(result.limits ?? null);
|
||||
const [photoResult, eventData, catalog] = await Promise.all([
|
||||
getEventPhotos(slug),
|
||||
getEvent(slug),
|
||||
getAddonCatalog(),
|
||||
]);
|
||||
setPhotos(photoResult.photos);
|
||||
setLimits(photoResult.limits ?? null);
|
||||
setEventAddons(eventData.addons ?? []);
|
||||
setAddons(catalog);
|
||||
setCatalogError(undefined);
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError(getApiErrorMessage(err, 'Fotos konnten nicht geladen werden.'));
|
||||
@@ -56,6 +71,18 @@ export default function EventPhotosPage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const success = searchParams.get('addon_success');
|
||||
if (success && slug) {
|
||||
toast(translateLimits('addonApplied', { defaultValue: 'Add-on angewendet. Limits aktualisieren sich in Kürze.' }));
|
||||
void load();
|
||||
const params = new URLSearchParams(searchParams);
|
||||
params.delete('addon_success');
|
||||
setSearchParams(params);
|
||||
navigate(window.location.pathname, { replace: true });
|
||||
}
|
||||
}, [searchParams, slug, load, navigate, translateLimits]);
|
||||
|
||||
async function handleToggleFeature(photo: TenantPhoto) {
|
||||
if (!slug) return;
|
||||
setBusyId(photo.id);
|
||||
@@ -126,7 +153,19 @@ export default function EventPhotosPage() {
|
||||
</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">
|
||||
<CardHeader>
|
||||
@@ -197,11 +236,49 @@ export default function EventPhotosPage() {
|
||||
function LimitWarningsBanner({
|
||||
limits,
|
||||
translate,
|
||||
eventSlug,
|
||||
addons,
|
||||
}: {
|
||||
limits: EventLimitSummary | null;
|
||||
translate: (key: string, options?: Record<string, unknown>) => string;
|
||||
eventSlug: string | null;
|
||||
addons: EventAddonCatalogItem[];
|
||||
}) {
|
||||
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) {
|
||||
return null;
|
||||
@@ -215,10 +292,36 @@ function LimitWarningsBanner({
|
||||
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
|
||||
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
|
||||
>
|
||||
<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">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
{warning.message}
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -71,6 +71,8 @@ return [
|
||||
'subject' => 'Foto-Uploads für „:event“ sind aktuell blockiert',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Fotos erreicht. Gäste können keine neuen Fotos hochladen, bis Sie das Paket upgraden.',
|
||||
'cta_addon' => 'Brauchen Sie sofort mehr Uploads? Nutzen Sie das Add-on im Event-Dashboard, um zusätzliche Slots in Sekunden freizuschalten.',
|
||||
'addon_action' => 'Mehr Fotos freischalten',
|
||||
'action' => 'Paket verwalten oder upgraden',
|
||||
],
|
||||
'guest_threshold' => [
|
||||
@@ -83,6 +85,8 @@ return [
|
||||
'subject' => 'Gästekontingent für „:event“ ist ausgeschöpft',
|
||||
'greeting' => 'Hallo :name,',
|
||||
'body' => 'Das Paket „:package“ für das Event „:event“ hat das Maximum von :limit Gästen erreicht. Neue Gästelinks können erst nach einem Upgrade erstellt werden.',
|
||||
'cta_addon' => 'Benötigen Sie sofort mehr Gästeplätze? Nutzen Sie das Add-on im Event-Dashboard, um direkt neue Slots freizuschalten.',
|
||||
'addon_action' => 'Mehr Gäste freischalten',
|
||||
'action' => 'Paket verwalten oder upgraden',
|
||||
],
|
||||
'event_threshold' => [
|
||||
@@ -129,4 +133,15 @@ return [
|
||||
],
|
||||
'footer' => 'Viele Grüße<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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -71,7 +71,9 @@ return [
|
||||
'subject' => 'Photo uploads for ":event" are currently blocked',
|
||||
'greeting' => 'Hello :name,',
|
||||
'body' => 'The package ":package" for event ":event" has reached its maximum of :limit photos. Guests can no longer upload new photos until you upgrade the package.',
|
||||
'cta_addon' => 'Need more uploads right now? Use the in-app add-on to unlock additional photo slots instantly.',
|
||||
'action' => 'Upgrade or manage package',
|
||||
'addon_action' => 'Unlock more photos',
|
||||
],
|
||||
'guest_threshold' => [
|
||||
'subject' => 'Event ":event" has used :percentage% of its guest allowance',
|
||||
@@ -83,7 +85,9 @@ return [
|
||||
'subject' => 'Guest slots for ":event" are currently exhausted',
|
||||
'greeting' => 'Hello :name,',
|
||||
'body' => 'The package ":package" for event ":event" has reached its maximum of :limit guests. New guest invites cannot be created until you upgrade the package.',
|
||||
'cta_addon' => 'Need more guest access right away? Use the add-on button inside the event dashboard to unlock extra slots within seconds.',
|
||||
'action' => 'Upgrade or manage package',
|
||||
'addon_action' => 'Unlock more guests',
|
||||
],
|
||||
'event_threshold' => [
|
||||
'subject' => 'Package ":package" has used :percentage% of its event allowance',
|
||||
@@ -129,4 +133,15 @@ return [
|
||||
],
|
||||
'footer' => 'Best regards,<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',
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
44
resources/views/emails/addons/receipt.blade.php
Normal file
44
resources/views/emails/addons/receipt.blade.php
Normal 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
|
||||
@@ -7,6 +7,8 @@ use App\Http\Controllers\Api\Marketing\CouponPreviewController;
|
||||
use App\Http\Controllers\Api\PackageController;
|
||||
use App\Http\Controllers\Api\Tenant\DashboardController;
|
||||
use App\Http\Controllers\Api\Tenant\EmotionController;
|
||||
use App\Http\Controllers\Api\Tenant\EventAddonCatalogController;
|
||||
use App\Http\Controllers\Api\Tenant\EventAddonController;
|
||||
use App\Http\Controllers\Api\Tenant\EventController;
|
||||
use App\Http\Controllers\Api\Tenant\EventGuestNotificationController;
|
||||
use App\Http\Controllers\Api\Tenant\EventJoinTokenController;
|
||||
@@ -148,6 +150,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit');
|
||||
Route::get('guest-notifications', [EventGuestNotificationController::class, 'index'])->name('tenant.events.guest-notifications.index');
|
||||
Route::post('guest-notifications', [EventGuestNotificationController::class, 'store'])->name('tenant.events.guest-notifications.store');
|
||||
Route::post('addons/apply', [EventAddonController::class, 'apply'])->name('tenant.events.addons.apply');
|
||||
Route::post('addons/checkout', [EventAddonController::class, 'checkout'])->name('tenant.events.addons.checkout');
|
||||
});
|
||||
|
||||
Route::prefix('join-tokens')->group(function () {
|
||||
@@ -266,13 +270,25 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
|
||||
Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout');
|
||||
});
|
||||
|
||||
Route::get('addons/catalog', [EventAddonCatalogController::class, 'index'])
|
||||
->middleware('tenant.admin')
|
||||
->name('tenant.addons.catalog');
|
||||
|
||||
Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index');
|
||||
});
|
||||
|
||||
Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions'])
|
||||
->middleware('tenant.admin')
|
||||
Route::prefix('billing')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('transactions', [TenantBillingController::class, 'transactions'])
|
||||
->name('tenant.billing.transactions');
|
||||
Route::get('addons', [TenantBillingController::class, 'addons'])
|
||||
->name('tenant.billing.addons');
|
||||
});
|
||||
|
||||
Route::prefix('tenant/billing')->middleware('tenant.admin')->group(function () {
|
||||
Route::get('transactions', [TenantBillingController::class, 'transactions']);
|
||||
Route::get('addons', [TenantBillingController::class, 'addons']);
|
||||
});
|
||||
|
||||
Route::post('feedback', [TenantFeedbackController::class, 'store'])
|
||||
->name('tenant.feedback.store');
|
||||
|
||||
102
tests/Feature/Api/Tenant/BillingAddonHistoryTest.php
Normal file
102
tests/Feature/Api/Tenant/BillingAddonHistoryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
52
tests/Feature/Api/Tenant/EventAddonsSummaryTest.php
Normal file
52
tests/Feature/Api/Tenant/EventAddonsSummaryTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
81
tests/Feature/Tenant/EventAddonCheckoutTest.php
Normal file
81
tests/Feature/Tenant/EventAddonCheckoutTest.php
Normal 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
|
||||
}
|
||||
}
|
||||
78
tests/Feature/Tenant/EventAddonControllerTest.php
Normal file
78
tests/Feature/Tenant/EventAddonControllerTest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
83
tests/Feature/Tenant/EventAddonWebhookTest.php
Normal file
83
tests/Feature/Tenant/EventAddonWebhookTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
42
tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php
Normal file
42
tests/Unit/Jobs/SyncPackageAddonToPaddleTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
43
tests/Unit/Services/EventAddonCatalogTest.php
Normal file
43
tests/Unit/Services/EventAddonCatalogTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
@@ -165,4 +165,40 @@ class PackageLimitEvaluatorTest extends TestCase
|
||||
$this->assertTrue($summary['can_upload_photos']);
|
||||
$this->assertTrue($summary['can_add_guests']);
|
||||
}
|
||||
|
||||
public function test_assess_photo_upload_respects_extra_limits(): void
|
||||
{
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_photos' => 5,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$event = Event::factory()
|
||||
->for($tenant)
|
||||
->create();
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 5,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(14),
|
||||
'extra_photos' => 5,
|
||||
])->fresh(['package']);
|
||||
|
||||
$violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id);
|
||||
|
||||
$this->assertNull($violation, 'Upload should be allowed within extra photo allowance');
|
||||
|
||||
$eventPackage->update(['used_photos' => 10]);
|
||||
|
||||
$violation = $this->evaluator->assessPhotoUpload($tenant->fresh(), $event->id);
|
||||
|
||||
$this->assertNotNull($violation, 'Upload should be blocked after exceeding base + extras');
|
||||
$this->assertSame('photo_limit_exceeded', $violation['code']);
|
||||
$this->assertSame(0, $violation['meta']['remaining']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,4 +145,41 @@ class PackageUsageTrackerTest extends TestCase
|
||||
|
||||
EventFacade::assertDispatched(EventPackageGuestLimitReached::class);
|
||||
}
|
||||
|
||||
public function test_effective_limits_include_extras(): void
|
||||
{
|
||||
EventFacade::fake([
|
||||
EventPackagePhotoLimitReached::class,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::factory()->create();
|
||||
$package = Package::factory()->endcustomer()->create([
|
||||
'max_photos' => 2,
|
||||
]);
|
||||
$event = Event::factory()->for($tenant)->create();
|
||||
|
||||
$eventPackage = EventPackage::create([
|
||||
'event_id' => $event->id,
|
||||
'package_id' => $package->id,
|
||||
'purchased_price' => $package->price,
|
||||
'purchased_at' => now(),
|
||||
'used_photos' => 2,
|
||||
'used_guests' => 0,
|
||||
'gallery_expires_at' => now()->addDays(7),
|
||||
'extra_photos' => 2,
|
||||
])->fresh(['package']);
|
||||
|
||||
/** @var PackageUsageTracker $tracker */
|
||||
$tracker = app(PackageUsageTracker::class);
|
||||
|
||||
// Base limit reached but extras still available; no limit event expected yet.
|
||||
$tracker->recordPhotoUsage($eventPackage, 1, 1);
|
||||
EventFacade::assertNotDispatched(EventPackagePhotoLimitReached::class);
|
||||
|
||||
// Now consume extras and hit the effective limit.
|
||||
$eventPackage->used_photos = 4;
|
||||
$tracker->recordPhotoUsage($eventPackage, 3, 1);
|
||||
|
||||
EventFacade::assertDispatched(EventPackagePhotoLimitReached::class);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user