feat(addons): finalize event addon catalog and ai styling upgrade flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-07 12:35:07 +01:00
parent 8cc0918881
commit d2808ffa4f
36 changed files with 1372 additions and 457 deletions

View File

@@ -17,18 +17,43 @@ class EventAddonCatalog
->orderBy('sort')
->get()
->mapWithKeys(function (PackageAddon $addon) {
$metadata = $this->normalizeMetadata($addon->metadata);
return [$addon->key => [
'label' => $addon->label,
'variant_id' => $addon->variant_id,
'increments' => $addon->increments,
'price' => $this->resolveMetadataPrice($addon->metadata ?? []),
'price' => $addon->resolvePaypalPrice(),
'currency' => 'EUR',
'metadata' => $metadata,
]];
})
->all();
// Fallback to config and merge (DB wins)
$configAddons = config('package-addons', []);
$configAddons = collect((array) config('package-addons', []))
->filter(static fn (mixed $addon): bool => is_array($addon))
->map(function (array $addon): array {
$metadata = $this->normalizeMetadata($addon['metadata'] ?? []);
if (! isset($metadata['scope']) && is_string($addon['scope'] ?? null) && trim((string) $addon['scope']) !== '') {
$metadata['scope'] = trim((string) $addon['scope']);
}
if (! isset($metadata['entitlements']) && is_array($addon['entitlements'] ?? null)) {
$metadata['entitlements'] = $addon['entitlements'];
}
return [
'label' => $addon['label'] ?? null,
'variant_id' => $addon['variant_id'] ?? null,
'increments' => Arr::get($addon, 'increments', []),
'price' => $this->resolveConfiguredPrice($addon),
'currency' => $addon['currency'] ?? 'EUR',
'metadata' => $metadata,
];
})
->all();
return array_merge($configAddons, $dbAddons);
}
@@ -61,13 +86,29 @@ class EventAddonCatalog
return is_numeric($price) ? (float) $price : null;
}
protected function resolveMetadataPrice(array $metadata): ?float
protected function resolveConfiguredPrice(array $addon): ?float
{
$price = $metadata['price_eur'] ?? null;
$price = $addon['price'] ?? $addon['price_eur'] ?? null;
return is_numeric($price) ? (float) $price : null;
}
/**
* @return array<string, mixed>
*/
protected function normalizeMetadata(mixed $metadata): array
{
if (is_string($metadata)) {
$decoded = json_decode($metadata, true);
if (json_last_error() === JSON_ERROR_NONE) {
$metadata = $decoded;
}
}
return is_array($metadata) ? $metadata : [];
}
/**
* @return array<string, int>
*/

View File

@@ -9,6 +9,7 @@ use App\Models\Tenant;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -31,8 +32,9 @@ class EventAddonCheckoutService
$quantity = max(1, (int) ($payload['quantity'] ?? 1));
$acceptedWaiver = (bool) ($payload['accepted_waiver'] ?? false);
$acceptedTerms = (bool) ($payload['accepted_terms'] ?? false);
$addon = is_string($addonKey) ? $this->catalog->find($addonKey) : null;
if (! $addonKey || ! $this->catalog->find($addonKey)) {
if (! is_string($addonKey) || $addon === null) {
throw ValidationException::withMessages([
'addon_key' => __('Unbekanntes Add-on.'),
]);
@@ -42,27 +44,29 @@ class EventAddonCheckoutService
if (! $event->eventPackage) {
throw ValidationException::withMessages([
'event' => __('Kein Paket für dieses Event hinterlegt.'),
'event' => __('Kein Paket fuer dieses Event hinterlegt.'),
]);
}
$provider = $this->resolveProvider();
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
return $this->createPayPalCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
return $this->createPayPalCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $addon, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
/**
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
* @param array<string, mixed> $addon
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
*/
protected function createLemonSqueezyCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
array $addon,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
@@ -72,14 +76,18 @@ class EventAddonCheckoutService
if (! $variantId) {
throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
'addon_key' => __('Fuer dieses Add-on ist keine Lemon Squeezy Variant hinterlegt.'),
]);
}
$addonIntent = (string) Str::uuid();
$increments = $this->catalog->resolveIncrements($addonKey);
$addonLabel = $this->resolveAddonLabel($addonKey, $addon);
$addonMetadata = $this->resolveAddonMetadata($addon);
$entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata);
$price = $this->catalog->resolvePrice($addonKey);
$metadata = array_filter([
$providerMetadata = array_filter([
'tenant_id' => (string) $tenant->id,
'event_id' => (string) $event->id,
'event_package_id' => (string) $event->eventPackage->id,
@@ -94,7 +102,7 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$response = $this->checkout->createVariantCheckout($variantId, $metadata, [
$response = $this->checkout->createVariantCheckout($variantId, $providerMetadata, [
'success_url' => $payload['success_url'] ?? null,
'return_url' => $payload['cancel_url'] ?? null,
'customer_email' => $tenant->contact_email ?? $tenant->user?->email,
@@ -117,15 +125,19 @@ class EventAddonCheckoutService
'checkout_id' => $checkoutId,
'transaction_id' => null,
'status' => 'pending',
'metadata' => array_merge($metadata, [
'metadata' => array_filter(array_merge($providerMetadata, [
'label' => $addonLabel,
'scope' => Arr::get($addonMetadata, 'scope'),
'entitlements' => $entitlements === [] ? null : $entitlements,
'price_eur' => $price,
'increments' => $increments,
'provider_payload' => $response,
'consents' => [
'legal_version' => $metadata['legal_version'],
'legal_version' => $providerMetadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]),
]), static fn (mixed $value): bool => $value !== null),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
@@ -140,23 +152,27 @@ class EventAddonCheckoutService
/**
* @param array{addon_key: string, quantity?: int, success_url?: string|null, cancel_url?: string|null} $payload
* @param array<string, mixed> $addon
* @return array{checkout_url: string|null, id: string|null, expires_at: string|null}
*/
protected function createPayPalCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
array $addon,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
array $payload,
): array {
$price = $this->catalog->resolvePrice($addonKey);
$addonLabel = $this->catalog->find($addonKey)['label'] ?? $addonKey;
$addonLabel = $this->resolveAddonLabel($addonKey, $addon);
$addonMetadata = $this->resolveAddonMetadata($addon);
$entitlements = $this->resolveAddonEntitlements($addonKey, $addonMetadata);
if (! $price) {
throw ValidationException::withMessages([
'addon_key' => __('Dieses Add-on ist aktuell nicht verfügbar.'),
'addon_key' => __('Dieses Add-on ist aktuell nicht verfuegbar.'),
]);
}
@@ -181,7 +197,7 @@ class EventAddonCheckoutService
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$addon = EventPackageAddon::create([
$addonPurchase = EventPackageAddon::create([
'event_package_id' => $event->eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $tenant->id,
@@ -193,14 +209,18 @@ class EventAddonCheckoutService
'status' => 'pending',
'amount' => $amount,
'currency' => $currency,
'metadata' => array_merge($metadata, [
'metadata' => array_filter(array_merge($metadata, [
'label' => $addonLabel,
'scope' => Arr::get($addonMetadata, 'scope'),
'entitlements' => $entitlements === [] ? null : $entitlements,
'price_eur' => $price,
'increments' => $increments,
'consents' => [
'legal_version' => $metadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]),
]), static fn (mixed $value): bool => $value !== null),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
@@ -212,26 +232,26 @@ class EventAddonCheckoutService
try {
$order = $this->paypalOrders->createSimpleOrder(
referenceId: 'addon-'.$addon->id,
referenceId: 'addon-'.$addonPurchase->id,
description: $addonLabel,
amount: $amount,
currency: $currency,
options: [
'custom_id' => 'addon_'.$addon->id,
'custom_id' => 'addon_'.$addonPurchase->id,
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $tenant->user?->preferred_locale ?? app()->getLocale(),
'request_id' => 'addon-'.$addon->id,
'request_id' => 'addon-'.$addonPurchase->id,
],
);
} catch (PayPalException $exception) {
Log::warning('PayPal addon checkout creation failed', [
'addon_id' => $addon->id,
'addon_id' => $addonPurchase->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
$addon->forceFill([
$addonPurchase->forceFill([
'status' => 'failed',
'error' => $exception->getMessage(),
])->save();
@@ -244,7 +264,7 @@ class EventAddonCheckoutService
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
$addon->forceFill([
$addonPurchase->forceFill([
'status' => 'failed',
'error' => 'PayPal order ID missing.',
])->save();
@@ -256,9 +276,9 @@ class EventAddonCheckoutService
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$addon->forceFill([
$addonPurchase->forceFill([
'checkout_id' => $orderId,
'metadata' => array_merge($addon->metadata ?? [], array_filter([
'metadata' => array_merge($addonPurchase->metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
@@ -291,4 +311,61 @@ class EventAddonCheckoutService
{
return config('app.legal_version', now()->toDateString());
}
/**
* @param array<string, mixed> $addon
*/
protected function resolveAddonLabel(string $addonKey, array $addon): string
{
$label = trim((string) ($addon['label'] ?? ''));
return $label !== '' ? $label : $addonKey;
}
/**
* @param array<string, mixed> $addon
* @return array<string, mixed>
*/
protected function resolveAddonMetadata(array $addon): array
{
$metadata = $addon['metadata'] ?? [];
return is_array($metadata) ? $metadata : [];
}
/**
* @param array<string, mixed> $addonMetadata
* @return array<string, mixed>
*/
protected function resolveAddonEntitlements(string $addonKey, array $addonMetadata): array
{
$entitlements = Arr::get($addonMetadata, 'entitlements', []);
$entitlements = is_array($entitlements) ? $entitlements : [];
$features = Arr::get($entitlements, 'features', []);
$features = is_array($features) ? $features : [];
if ($features === [] && in_array($addonKey, (array) config('ai-editing.entitlements.addon_keys', []), true)) {
$requiredFeature = trim((string) config('ai-editing.entitlements.package_feature', 'ai_styling'));
if ($requiredFeature !== '') {
$features[] = $requiredFeature;
}
}
$normalizedFeatures = array_values(array_unique(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$features,
))));
if ($normalizedFeatures !== []) {
$entitlements['features'] = $normalizedFeatures;
}
if (is_string(Arr::get($entitlements, 'expires_at'))) {
$entitlements['expires_at'] = trim((string) $entitlements['expires_at']);
}
return $entitlements;
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Services\Addons;
use App\Models\Event;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Models\Package;
use Carbon\CarbonImmutable;
use Illuminate\Support\Arr;
class EventFeatureEntitlementService
{
/**
* @param array<int, string> $addonKeys
* @return array{
* allowed: bool,
* granted_by: 'package'|'addon'|null,
* required_feature: string,
* addon_keys: array<int, string>
* }
*/
public function resolveForEvent(Event $event, string $requiredFeature, array $addonKeys = []): array
{
$eventPackage = $this->resolveEventPackage($event);
if (! $eventPackage) {
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->packageGrantsFeature($eventPackage->package, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'package',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
if ($this->addonGrantsFeature($eventPackage, $addonKeys, $requiredFeature)) {
return [
'allowed' => true,
'granted_by' => 'addon',
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
return [
'allowed' => false,
'granted_by' => null,
'required_feature' => $requiredFeature,
'addon_keys' => $addonKeys,
];
}
private function resolveEventPackage(Event $event): ?EventPackage
{
$event->loadMissing('eventPackage.package', 'eventPackage.addons');
if ($event->eventPackage) {
return $event->eventPackage;
}
return $event->eventPackages()
->with(['package', 'addons'])
->orderByDesc('purchased_at')
->orderByDesc('id')
->first();
}
private function packageGrantsFeature(?Package $package, string $requiredFeature): bool
{
if (! $package) {
return false;
}
return in_array($requiredFeature, $this->normalizeFeatureList($package->features), true);
}
/**
* @param array<int, string> $addonKeys
*/
private function addonGrantsFeature(EventPackage $eventPackage, array $addonKeys, string $requiredFeature): bool
{
$addons = $eventPackage->relationLoaded('addons')
? $eventPackage->addons
: $eventPackage->addons()
->where('status', 'completed')
->get();
return $addons->contains(function (EventPackageAddon $addon) use ($addonKeys, $requiredFeature): bool {
if (! $this->addonIsActive($addon)) {
return false;
}
if ($addonKeys !== [] && in_array((string) $addon->addon_key, $addonKeys, true)) {
return true;
}
$metadataFeatures = $this->normalizeFeatureList(
Arr::get($addon->metadata ?? [], 'entitlements.features', Arr::get($addon->metadata ?? [], 'features', []))
);
return in_array($requiredFeature, $metadataFeatures, true);
});
}
private function addonIsActive(EventPackageAddon $addon): bool
{
if ($addon->status !== 'completed') {
return false;
}
$expiryCandidates = [
Arr::get($addon->metadata ?? [], 'entitlements.expires_at'),
Arr::get($addon->metadata ?? [], 'expires_at'),
Arr::get($addon->metadata ?? [], 'valid_until'),
];
foreach ($expiryCandidates as $candidate) {
if (! is_string($candidate) || trim($candidate) === '') {
continue;
}
try {
$expiresAt = CarbonImmutable::parse($candidate);
} catch (\Throwable) {
continue;
}
if ($expiresAt->isPast()) {
return false;
}
}
return true;
}
/**
* @return array<int, string>
*/
private function normalizeFeatureList(mixed $value): array
{
if (is_string($value)) {
$decoded = json_decode($value, true);
if (json_last_error() === JSON_ERROR_NONE) {
$value = $decoded;
}
}
if (! is_array($value)) {
return [];
}
if (array_is_list($value)) {
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
$value
)));
}
return array_values(array_filter(array_map(
static fn (mixed $feature): string => trim((string) $feature),
array_keys(array_filter($value, static fn (mixed $enabled): bool => (bool) $enabled))
)));
}
}