Add PayPal support for add-on and gift voucher checkout
This commit is contained in:
@@ -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>
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
|
||||
117
app/Services/Addons/EventAddonPurchaseService.php
Normal file
117
app/Services/Addons/EventAddonPurchaseService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user