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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests\Tenant;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class EventAddonRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'extra_photos' => ['nullable', 'integer', 'min:1'],
'extra_guests' => ['nullable', 'integer', 'min:1'],
'extend_gallery_days' => ['nullable', 'integer', 'min:1'],
'reason' => ['nullable', 'string', 'max:255'],
];
}
protected function passedValidation(): void
{
if (
$this->input('extra_photos') === null
&& $this->input('extra_guests') === null
&& $this->input('extend_gallery_days') === null
) {
throw ValidationException::withMessages([
'addons' => __('Please provide at least one add-on to apply.'),
]);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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