implemented event package addons with filament resource, event-admin purchase path and notifications, showing up in purchase history
This commit is contained in:
63
app/Services/Addons/EventAddonCatalog.php
Normal file
63
app/Services/Addons/EventAddonCatalog.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class EventAddonCatalog
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function all(): array
|
||||
{
|
||||
$dbAddons = PackageAddon::query()
|
||||
->where('active', true)
|
||||
->orderBy('sort')
|
||||
->get()
|
||||
->mapWithKeys(function (PackageAddon $addon) {
|
||||
return [$addon->key => [
|
||||
'label' => $addon->label,
|
||||
'price_id' => $addon->price_id,
|
||||
'increments' => $addon->increments,
|
||||
]];
|
||||
})
|
||||
->all();
|
||||
|
||||
// Fallback to config and merge (DB wins)
|
||||
$configAddons = config('package-addons', []);
|
||||
|
||||
return array_merge($configAddons, $dbAddons);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function find(string $key): ?array
|
||||
{
|
||||
return $this->all()[$key] ?? null;
|
||||
}
|
||||
|
||||
public function resolvePriceId(string $key): ?string
|
||||
{
|
||||
$addon = $this->find($key);
|
||||
|
||||
return $addon['price_id'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
public function resolveIncrements(string $key): array
|
||||
{
|
||||
$addon = $this->find($key) ?? [];
|
||||
|
||||
$increments = Arr::get($addon, 'increments', []);
|
||||
|
||||
return collect($increments)
|
||||
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
|
||||
->filter(fn ($value) => $value > 0)
|
||||
->all();
|
||||
}
|
||||
}
|
||||
112
app/Services/Addons/EventAddonCheckoutService.php
Normal file
112
app/Services/Addons/EventAddonCheckoutService.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Paddle\PaddleClient;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class EventAddonCheckoutService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EventAddonCatalog $catalog,
|
||||
private readonly PaddleClient $paddle,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
|
||||
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
|
||||
*/
|
||||
public function createCheckout(Tenant $tenant, Event $event, array $payload): array
|
||||
{
|
||||
$addonKey = $payload['addon_key'] ?? null;
|
||||
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
|
||||
|
||||
if (! $addonKey || ! $this->catalog->find($addonKey)) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Unbekanntes Add-on.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$priceId = $this->catalog->resolvePriceId($addonKey);
|
||||
|
||||
if (! $priceId) {
|
||||
throw ValidationException::withMessages([
|
||||
'addon_key' => __('Für dieses Add-on ist kein Paddle-Preis hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$event->loadMissing('eventPackage');
|
||||
|
||||
if (! $event->eventPackage) {
|
||||
throw ValidationException::withMessages([
|
||||
'event' => __('Kein Paket für dieses Event hinterlegt.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$addonIntent = (string) Str::uuid();
|
||||
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
|
||||
$metadata = [
|
||||
'tenant_id' => (string) $tenant->id,
|
||||
'event_id' => (string) $event->id,
|
||||
'event_package_id' => (string) $event->eventPackage->id,
|
||||
'addon_key' => $addonKey,
|
||||
'addon_intent' => $addonIntent,
|
||||
'quantity' => $quantity,
|
||||
];
|
||||
|
||||
$requestPayload = array_filter([
|
||||
'customer_id' => $tenant->paddle_customer_id,
|
||||
'items' => [
|
||||
[
|
||||
'price_id' => $priceId,
|
||||
'quantity' => $quantity,
|
||||
],
|
||||
],
|
||||
'metadata' => $metadata,
|
||||
'success_url' => $payload['success_url'] ?? null,
|
||||
'cancel_url' => $payload['cancel_url'] ?? null,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
|
||||
$response = $this->paddle->post('/checkout/links', $requestPayload);
|
||||
|
||||
$checkoutUrl = Arr::get($response, 'data.url') ?? Arr::get($response, 'url');
|
||||
$checkoutId = Arr::get($response, 'data.id') ?? Arr::get($response, 'id');
|
||||
|
||||
if (! $checkoutUrl) {
|
||||
Log::warning('Paddle addon checkout response missing url', ['response' => $response]);
|
||||
}
|
||||
|
||||
EventPackageAddon::create([
|
||||
'event_package_id' => $event->eventPackage->id,
|
||||
'event_id' => $event->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'addon_key' => $addonKey,
|
||||
'quantity' => $quantity,
|
||||
'price_id' => $priceId,
|
||||
'checkout_id' => $checkoutId,
|
||||
'transaction_id' => null,
|
||||
'status' => 'pending',
|
||||
'metadata' => array_merge($metadata, [
|
||||
'increments' => $increments,
|
||||
'provider_payload' => $response,
|
||||
]),
|
||||
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
|
||||
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
|
||||
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
|
||||
]);
|
||||
|
||||
return [
|
||||
'checkout_url' => $checkoutUrl,
|
||||
'expires_at' => Arr::get($response, 'data.expires_at') ?? Arr::get($response, 'expires_at'),
|
||||
'id' => $checkoutId,
|
||||
];
|
||||
}
|
||||
}
|
||||
125
app/Services/Addons/EventAddonWebhookService.php
Normal file
125
app/Services/Addons/EventAddonWebhookService.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Addons;
|
||||
|
||||
use App\Models\EventPackage;
|
||||
use App\Models\EventPackageAddon;
|
||||
use App\Notifications\Addons\AddonPurchaseReceipt;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
|
||||
class EventAddonWebhookService
|
||||
{
|
||||
public function __construct(private readonly EventAddonCatalog $catalog) {}
|
||||
|
||||
public function handle(array $payload): bool
|
||||
{
|
||||
$eventType = $payload['event_type'] ?? null;
|
||||
$data = $payload['data'] ?? [];
|
||||
|
||||
if ($eventType !== 'transaction.completed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$metadata = $this->extractMetadata($data);
|
||||
$intentId = $metadata['addon_intent'] ?? null;
|
||||
$addonKey = $metadata['addon_key'] ?? null;
|
||||
|
||||
if (! $intentId || ! $addonKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionId = $data['id'] ?? $data['transaction_id'] ?? null;
|
||||
$checkoutId = $data['checkout_id'] ?? null;
|
||||
|
||||
$addon = EventPackageAddon::query()
|
||||
->where('addon_key', $addonKey)
|
||||
->where(function ($query) use ($intentId, $checkoutId, $transactionId) {
|
||||
$query->where('metadata->addon_intent', $intentId)
|
||||
->orWhere('checkout_id', $checkoutId)
|
||||
->orWhere('transaction_id', $transactionId);
|
||||
})
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if (! $addon) {
|
||||
Log::info('[AddonWebhook] Add-on intent not found', [
|
||||
'intent' => $intentId,
|
||||
'addon_key' => $addonKey,
|
||||
'transaction_id' => $transactionId,
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($addon->status === 'completed') {
|
||||
return true; // idempotent
|
||||
}
|
||||
|
||||
$increments = $this->catalog->resolveIncrements($addonKey);
|
||||
|
||||
DB::transaction(function () use ($addon, $transactionId, $checkoutId, $data, $increments) {
|
||||
$addon->forceFill([
|
||||
'transaction_id' => $transactionId,
|
||||
'checkout_id' => $addon->checkout_id ?: $checkoutId,
|
||||
'status' => 'completed',
|
||||
'amount' => Arr::get($data, 'totals.grand_total') ?? Arr::get($data, 'amount'),
|
||||
'currency' => Arr::get($data, 'currency_code') ?? Arr::get($data, 'currency'),
|
||||
'metadata' => array_merge($addon->metadata ?? [], ['webhook_payload' => $data]),
|
||||
'receipt_payload' => Arr::get($data, 'receipt_url') ? ['receipt_url' => Arr::get($data, 'receipt_url')] : null,
|
||||
'purchased_at' => now(),
|
||||
])->save();
|
||||
|
||||
/** @var EventPackage $eventPackage */
|
||||
$eventPackage = EventPackage::query()
|
||||
->lockForUpdate()
|
||||
->find($addon->event_package_id);
|
||||
|
||||
if (! $eventPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventPackage->forceFill([
|
||||
'extra_photos' => ($eventPackage->extra_photos ?? 0) + (int) ($increments['extra_photos'] ?? 0) * $addon->quantity,
|
||||
'extra_guests' => ($eventPackage->extra_guests ?? 0) + (int) ($increments['extra_guests'] ?? 0) * $addon->quantity,
|
||||
'extra_gallery_days' => ($eventPackage->extra_gallery_days ?? 0) + (int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity,
|
||||
]);
|
||||
|
||||
if (($increments['extra_gallery_days'] ?? 0) > 0) {
|
||||
$base = $eventPackage->gallery_expires_at ?? now();
|
||||
$eventPackage->gallery_expires_at = $base->copy()->addDays((int) ($increments['extra_gallery_days'] ?? 0) * $addon->quantity);
|
||||
}
|
||||
|
||||
$eventPackage->save();
|
||||
|
||||
$tenant = $addon->tenant;
|
||||
if ($tenant) {
|
||||
Notification::route('mail', [$tenant->contact_email ?? $tenant->user?->email])
|
||||
->notify(new AddonPurchaseReceipt($addon));
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function extractMetadata(array $data): array
|
||||
{
|
||||
$metadata = [];
|
||||
|
||||
if (isset($data['metadata']) && is_array($data['metadata'])) {
|
||||
$metadata = $data['metadata'];
|
||||
}
|
||||
|
||||
if (isset($data['custom_data']) && is_array($data['custom_data'])) {
|
||||
$metadata = array_merge($metadata, $data['custom_data']);
|
||||
}
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ class PackageLimitEvaluator
|
||||
];
|
||||
}
|
||||
|
||||
$maxPhotos = $eventPackage->package->max_photos;
|
||||
$maxPhotos = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($maxPhotos === null) {
|
||||
return null;
|
||||
@@ -115,17 +115,17 @@ class PackageLimitEvaluator
|
||||
|
||||
public function summarizeEventPackage(EventPackage $eventPackage): array
|
||||
{
|
||||
$package = $eventPackage->package;
|
||||
$limits = $eventPackage->effectiveLimits();
|
||||
|
||||
$photoSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_photos,
|
||||
$package?->max_photos,
|
||||
$limits['max_photos'],
|
||||
config('package-limits.photo_thresholds', [])
|
||||
);
|
||||
|
||||
$guestSummary = $this->buildUsageSummary(
|
||||
(int) $eventPackage->used_guests,
|
||||
$package?->max_guests,
|
||||
$limits['max_guests'],
|
||||
config('package-limits.guest_thresholds', [])
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordPhotoUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
@@ -51,7 +51,7 @@ class PackageUsageTracker
|
||||
|
||||
public function recordGuestUsage(EventPackage $eventPackage, int $previousUsed, int $delta = 1): void
|
||||
{
|
||||
$limit = $eventPackage->package?->max_guests;
|
||||
$limit = $eventPackage->effectiveGuestLimit();
|
||||
|
||||
if ($limit === null || $limit <= 0) {
|
||||
return;
|
||||
|
||||
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal file
137
app/Services/Paddle/PaddleAddonCatalogService.php
Normal file
@@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Paddle;
|
||||
|
||||
use App\Models\PackageAddon;
|
||||
use App\Services\Paddle\Exceptions\PaddleException;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class PaddleAddonCatalogService
|
||||
{
|
||||
public function __construct(private readonly PaddleClient $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createProduct(PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildProductPayload($addon, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->post('/products', $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updateProduct(string $productId, PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildProductPayload($addon, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->patch("/products/{$productId}", $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function createPrice(PackageAddon $addon, string $productId, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($addon, $productId, $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->post('/prices', $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function updatePrice(string $priceId, PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = $this->buildPricePayload($addon, $overrides['product_id'] ?? '', $overrides);
|
||||
|
||||
return $this->extractEntity($this->client->patch("/prices/{$priceId}", $payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildProductPayload(PackageAddon $addon, array $overrides = []): array
|
||||
{
|
||||
$payload = array_merge([
|
||||
'name' => $overrides['name'] ?? $addon->label,
|
||||
'description' => $overrides['description'] ?? sprintf('Fotospiel Add-on: %s', $addon->label),
|
||||
'tax_category' => $overrides['tax_category'] ?? 'standard',
|
||||
'type' => $overrides['type'] ?? 'standard',
|
||||
'custom_data' => array_merge([
|
||||
'addon_key' => $addon->key,
|
||||
'increments' => $addon->increments,
|
||||
], $overrides['custom_data'] ?? []),
|
||||
], Arr::except($overrides, ['tax_category', 'type', 'custom_data', 'name', 'description']));
|
||||
|
||||
return $this->cleanPayload($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function buildPricePayload(PackageAddon $addon, string $productId, array $overrides = []): array
|
||||
{
|
||||
$unitPrice = $overrides['unit_price'] ?? $this->defaultUnitPrice($addon);
|
||||
|
||||
$payload = array_merge([
|
||||
'product_id' => $productId,
|
||||
'description' => $overrides['description'] ?? $addon->label,
|
||||
'unit_price' => $unitPrice,
|
||||
'custom_data' => array_merge([
|
||||
'addon_key' => $addon->key,
|
||||
], $overrides['custom_data'] ?? []),
|
||||
], Arr::except($overrides, ['unit_price', 'description', 'custom_data', 'product_id']));
|
||||
|
||||
return $this->cleanPayload($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function extractEntity(array $payload): array
|
||||
{
|
||||
return Arr::get($payload, 'data', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function cleanPayload(array $payload): array
|
||||
{
|
||||
$filtered = collect($payload)
|
||||
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
|
||||
->all();
|
||||
|
||||
if (array_key_exists('custom_data', $filtered)) {
|
||||
$filtered['custom_data'] = collect($filtered['custom_data'])
|
||||
->reject(static fn ($value) => $value === null || $value === '' || $value === [])
|
||||
->all();
|
||||
}
|
||||
|
||||
return $filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function defaultUnitPrice(PackageAddon $addon): array
|
||||
{
|
||||
$metaPrice = $addon->metadata['price_eur'] ?? null;
|
||||
|
||||
if (! is_numeric($metaPrice)) {
|
||||
throw new PaddleException('No unit price specified for addon. Provide metadata[price_eur] or overrides.unit_price.');
|
||||
}
|
||||
|
||||
$amountCents = (int) round(((float) $metaPrice) * 100);
|
||||
|
||||
return [
|
||||
'amount' => (string) $amountCents,
|
||||
'currency_code' => 'EUR',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -239,11 +239,13 @@ class PhotoboothIngestService
|
||||
return false;
|
||||
}
|
||||
|
||||
$limit = $eventPackage->package->max_photos;
|
||||
$limit = $eventPackage->effectivePhotoLimit();
|
||||
|
||||
return $limit !== null
|
||||
&& $limit > 0
|
||||
&& $eventPackage->used_photos >= $limit;
|
||||
if ($limit === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $limit > 0 && $eventPackage->used_photos >= $limit;
|
||||
}
|
||||
|
||||
protected function resolveEmotionId(Event $event): ?int
|
||||
|
||||
Reference in New Issue
Block a user