Add PayPal support for add-on and gift voucher checkout

This commit is contained in:
Codex Agent
2026-02-04 14:54:40 +01:00
parent 7025418d9e
commit 17025df47b
24 changed files with 1599 additions and 34 deletions

View File

@@ -21,6 +21,8 @@ class EventAddonCatalog
'label' => $addon->label,
'variant_id' => $addon->variant_id,
'increments' => $addon->increments,
'price' => $this->resolveMetadataPrice($addon->metadata ?? []),
'currency' => 'EUR',
]];
})
->all();
@@ -46,6 +48,26 @@ class EventAddonCatalog
return $addon['variant_id'] ?? null;
}
public function resolvePrice(string $key): ?float
{
$addon = $this->find($key);
if (! $addon) {
return null;
}
$price = $addon['price'] ?? $addon['price_eur'] ?? null;
return is_numeric($price) ? (float) $price : null;
}
protected function resolveMetadataPrice(array $metadata): ?float
{
$price = $metadata['price_eur'] ?? null;
return is_numeric($price) ? (float) $price : null;
}
/**
* @return array<string, int>
*/

View File

@@ -2,10 +2,13 @@
namespace App\Services\Addons;
use App\Models\CheckoutSession;
use App\Models\Event;
use App\Models\EventPackageAddon;
use App\Models\Tenant;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
@@ -15,6 +18,7 @@ class EventAddonCheckoutService
public function __construct(
private readonly EventAddonCatalog $catalog,
private readonly LemonSqueezyCheckoutService $checkout,
private readonly PayPalOrderService $paypalOrders,
) {}
/**
@@ -34,24 +38,45 @@ class EventAddonCheckoutService
]);
}
$variantId = $this->catalog->resolveVariantId($addonKey);
if (! $variantId) {
throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
]);
}
$event->loadMissing('eventPackage');
if (! $event->eventPackage) {
throw ValidationException::withMessages([
'event' => __('Kein Paket für dieses Event hinterlegt.'),
'event' => __('Kein Paket für dieses Event hinterlegt.'),
]);
}
$provider = $this->resolveProvider();
if ($provider === CheckoutSession::PROVIDER_PAYPAL) {
return $this->createPayPalCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
return $this->createLemonSqueezyCheckout($tenant, $event, $addonKey, $quantity, $acceptedTerms, $acceptedWaiver, $payload);
}
/**
* @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}
*/
protected function createLemonSqueezyCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
array $payload,
): array {
$variantId = $this->catalog->resolveVariantId($addonKey);
if (! $variantId) {
throw ValidationException::withMessages([
'addon_key' => __('Für dieses Add-on ist kein Lemon Squeezy Variant hinterlegt.'),
]);
}
$addonIntent = (string) Str::uuid();
$increments = $this->catalog->resolveIncrements($addonKey);
$metadata = array_filter([
@@ -77,7 +102,6 @@ class EventAddonCheckoutService
$checkoutUrl = $response['checkout_url'] ?? null;
$checkoutId = $response['id'] ?? null;
$transactionId = null;
if (! $checkoutUrl) {
Log::warning('Lemon Squeezy addon checkout response missing url', ['response' => $response]);
@@ -91,7 +115,7 @@ class EventAddonCheckoutService
'quantity' => $quantity,
'variant_id' => $variantId,
'checkout_id' => $checkoutId,
'transaction_id' => $transactionId,
'transaction_id' => null,
'status' => 'pending',
'metadata' => array_merge($metadata, [
'increments' => $increments,
@@ -114,6 +138,155 @@ class EventAddonCheckoutService
];
}
/**
* @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}
*/
protected function createPayPalCheckout(
Tenant $tenant,
Event $event,
string $addonKey,
int $quantity,
bool $acceptedTerms,
bool $acceptedWaiver,
array $payload,
): array {
$price = $this->catalog->resolvePrice($addonKey);
$addonLabel = $this->catalog->find($addonKey)['label'] ?? $addonKey;
if (! $price) {
throw ValidationException::withMessages([
'addon_key' => __('Dieses Add-on ist aktuell nicht verfügbar.'),
]);
}
$addonIntent = (string) Str::uuid();
$increments = $this->catalog->resolveIncrements($addonKey);
$amount = round($price * $quantity, 2);
$currency = strtoupper((string) config('checkout.currency', 'EUR'));
$metadata = array_filter([
'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,
'price' => $price,
'currency' => $currency,
'legal_version' => $this->resolveLegalVersion(),
'accepted_terms' => $acceptedTerms ? '1' : '0',
'accepted_waiver' => $acceptedWaiver ? '1' : '0',
'success_url' => $payload['success_url'] ?? null,
'cancel_url' => $payload['cancel_url'] ?? null,
], static fn ($value) => $value !== null && $value !== '');
$addon = EventPackageAddon::create([
'event_package_id' => $event->eventPackage->id,
'event_id' => $event->id,
'tenant_id' => $tenant->id,
'addon_key' => $addonKey,
'quantity' => $quantity,
'variant_id' => null,
'checkout_id' => null,
'transaction_id' => null,
'status' => 'pending',
'amount' => $amount,
'currency' => $currency,
'metadata' => array_merge($metadata, [
'increments' => $increments,
'consents' => [
'legal_version' => $metadata['legal_version'],
'accepted_terms_at' => $acceptedTerms ? now()->toIso8601String() : null,
'digital_content_waiver_at' => $acceptedWaiver ? now()->toIso8601String() : null,
],
]),
'extra_photos' => ($increments['extra_photos'] ?? 0) * $quantity,
'extra_guests' => ($increments['extra_guests'] ?? 0) * $quantity,
'extra_gallery_days' => ($increments['extra_gallery_days'] ?? 0) * $quantity,
]);
$successUrl = $payload['success_url'] ?? null;
$cancelUrl = $payload['cancel_url'] ?? $successUrl;
$paypalReturnUrl = route('paypal.addon.return', absolute: true);
try {
$order = $this->paypalOrders->createSimpleOrder(
referenceId: 'addon-'.$addon->id,
description: $addonLabel,
amount: $amount,
currency: $currency,
options: [
'custom_id' => 'addon_'.$addon->id,
'return_url' => $paypalReturnUrl,
'cancel_url' => $paypalReturnUrl,
'locale' => $tenant->user?->preferred_locale ?? app()->getLocale(),
'request_id' => 'addon-'.$addon->id,
],
);
} catch (PayPalException $exception) {
Log::warning('PayPal addon checkout creation failed', [
'addon_id' => $addon->id,
'message' => $exception->getMessage(),
'status' => $exception->status(),
]);
$addon->forceFill([
'status' => 'failed',
'error' => $exception->getMessage(),
])->save();
throw ValidationException::withMessages([
'addon_key' => __('Add-on checkout failed.'),
]);
}
$orderId = $order['id'] ?? null;
if (! is_string($orderId) || $orderId === '') {
$addon->forceFill([
'status' => 'failed',
'error' => 'PayPal order ID missing.',
])->save();
throw ValidationException::withMessages([
'addon_key' => __('Add-on checkout failed.'),
]);
}
$approveUrl = $this->paypalOrders->resolveApproveUrl($order);
$addon->forceFill([
'checkout_id' => $orderId,
'metadata' => array_merge($addon->metadata ?? [], array_filter([
'paypal_order_id' => $orderId,
'paypal_approve_url' => $approveUrl,
'paypal_success_url' => $successUrl,
'paypal_cancel_url' => $cancelUrl,
'paypal_created_at' => now()->toIso8601String(),
])),
])->save();
return [
'checkout_url' => $approveUrl,
'expires_at' => null,
'id' => $orderId,
];
}
protected function resolveProvider(): ?string
{
$provider = config('package-addons.provider');
if (is_string($provider) && $provider !== '') {
return $provider;
}
$default = config('checkout.default_provider');
return is_string($default) && $default !== '' ? $default : null;
}
protected function resolveLegalVersion(): string
{
return config('app.legal_version', now()->toDateString());

View File

@@ -0,0 +1,117 @@
<?php
namespace App\Services\Addons;
use App\Models\EventPackage;
use App\Models\EventPackageAddon;
use App\Notifications\Addons\AddonPurchaseReceipt;
use App\Notifications\Ops\AddonPurchased;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
class EventAddonPurchaseService
{
public function __construct(private readonly EventAddonCatalog $catalog) {}
/**
* @param array<string, mixed> $payload
*/
public function complete(
EventPackageAddon $addon,
array $payload,
?string $transactionId,
?string $checkoutId,
?float $amount,
?string $currency,
array $metadata = [],
): void {
if ($addon->status === 'completed') {
return;
}
$increments = $this->resolveAddonIncrements($addon, $addon->addon_key);
DB::transaction(function () use ($addon, $payload, $transactionId, $checkoutId, $amount, $currency, $metadata, $increments) {
$addon->forceFill([
'transaction_id' => $transactionId ?? $addon->transaction_id,
'checkout_id' => $addon->checkout_id ?: $checkoutId,
'status' => 'completed',
'amount' => $amount ?? $addon->amount,
'currency' => $currency ?? $addon->currency,
'metadata' => array_merge($addon->metadata ?? [], array_filter([
'payment_payload' => $payload,
]), $metadata),
'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));
$opsEmail = config('mail.ops_address');
if ($opsEmail) {
Notification::route('mail', $opsEmail)->notify(new AddonPurchased($addon));
}
}
});
}
/**
* @param array<string, mixed> $payload
*/
public function fail(EventPackageAddon $addon, string $reason, array $payload = []): void
{
$addon->forceFill([
'status' => 'failed',
'error' => $reason,
'metadata' => array_merge($addon->metadata ?? [], array_filter([
'payment_payload' => $payload,
])),
])->save();
}
/**
* @return array<string, int>
*/
private function resolveAddonIncrements(EventPackageAddon $addon, string $addonKey): array
{
$stored = Arr::get($addon->metadata ?? [], 'increments', []);
if (is_array($stored) && $stored !== []) {
$filtered = collect($stored)
->map(fn ($value) => is_numeric($value) ? (int) $value : 0)
->filter(fn ($value) => $value > 0)
->all();
if ($filtered !== []) {
return $filtered;
}
}
return $this->catalog->resolveIncrements($addonKey);
}
}