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