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,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;
}
}