From 17025df47b41b9eb4ea1a32fb55ee03a6995c377 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 4 Feb 2026 14:54:40 +0100 Subject: [PATCH] Add PayPal support for add-on and gift voucher checkout --- .../GiftVoucherCheckoutController.php | 41 +++- .../Tenant/EventAddonCatalogController.php | 21 +- .../PayPalAddonReturnController.php | 150 +++++++++++++ .../PayPalGiftVoucherReturnController.php | 129 ++++++++++++ .../Controllers/PayPalWebhookController.php | 12 +- app/Models/GiftVoucher.php | 8 + app/Services/Addons/EventAddonCatalog.php | 22 ++ .../Addons/EventAddonCheckoutService.php | 197 ++++++++++++++++-- .../Addons/EventAddonPurchaseService.php | 117 +++++++++++ .../GiftVoucherCheckoutService.php | 140 ++++++++++++- .../GiftVouchers/GiftVoucherService.php | 67 ++++++ .../PayPal/PayPalAddonWebhookService.php | 166 +++++++++++++++ .../PayPalGiftVoucherWebhookService.php | 79 +++++++ app/Services/PayPal/PayPalOrderService.php | 55 +++++ config/gift-vouchers.php | 1 + config/package-addons.php | 2 + ..._paypal_columns_to_gift_vouchers_table.php | 40 ++++ routes/web.php | 8 + tests/Feature/Api/GiftVoucherLookupTest.php | 19 ++ tests/Feature/PayPalWebhookControllerTest.php | 149 +++++++++++++ .../Feature/Tenant/EventAddonCheckoutTest.php | 73 ++++++- tests/Unit/GiftVoucherCheckoutServiceTest.php | 64 ++++++ tests/Unit/GiftVoucherServiceTest.php | 62 ++++++ tests/Unit/Services/EventAddonCatalogTest.php | 11 +- 24 files changed, 1599 insertions(+), 34 deletions(-) create mode 100644 app/Http/Controllers/PayPalAddonReturnController.php create mode 100644 app/Http/Controllers/PayPalGiftVoucherReturnController.php create mode 100644 app/Services/Addons/EventAddonPurchaseService.php create mode 100644 app/Services/PayPal/PayPalAddonWebhookService.php create mode 100644 app/Services/PayPal/PayPalGiftVoucherWebhookService.php create mode 100644 database/migrations/2026_02_04_143507_add_paypal_columns_to_gift_vouchers_table.php diff --git a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php index c530ddd5..0ab759ba 100644 --- a/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php +++ b/app/Http/Controllers/Api/Marketing/GiftVoucherCheckoutController.php @@ -36,7 +36,7 @@ class GiftVoucherCheckoutController extends Controller if (! $checkout['checkout_url']) { throw ValidationException::withMessages([ - 'tier_key' => __('Unable to create Lemon Squeezy checkout.'), + 'tier_key' => __('Unable to create checkout.'), ]); } @@ -51,19 +51,38 @@ class GiftVoucherCheckoutController extends Controller 'code' => ['nullable', 'string', 'required_without_all:checkout_id,order_id'], ]); - $voucherQuery = GiftVoucher::query(); + $voucherQuery = GiftVoucher::query() + ->where('status', '!=', GiftVoucher::STATUS_PENDING) + ->where(function ($query) use ($data) { + $hasCondition = false; - if (! empty($data['checkout_id'])) { - $voucherQuery->where('lemonsqueezy_checkout_id', $data['checkout_id']); - } + if (! empty($data['checkout_id'])) { + $query->where(function ($inner) use ($data) { + $inner->where('lemonsqueezy_checkout_id', $data['checkout_id']) + ->orWhere('paypal_order_id', $data['checkout_id']); + }); - if (! empty($data['order_id'])) { - $voucherQuery->orWhere('lemonsqueezy_order_id', $data['order_id']); - } + $hasCondition = true; + } - if (! empty($data['code'])) { - $voucherQuery->orWhere('code', strtoupper($data['code'])); - } + if (! empty($data['order_id'])) { + $method = $hasCondition ? 'orWhere' : 'where'; + + $query->{$method}(function ($inner) use ($data) { + $inner->where('lemonsqueezy_order_id', $data['order_id']) + ->orWhere('paypal_capture_id', $data['order_id']) + ->orWhere('paypal_order_id', $data['order_id']); + }); + + $hasCondition = true; + } + + if (! empty($data['code'])) { + $method = $hasCondition ? 'orWhere' : 'where'; + + $query->{$method}('code', strtoupper($data['code'])); + } + }); $voucher = $voucherQuery->latest()->firstOrFail(); diff --git a/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php b/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php index 47e86a7a..ee60efb4 100644 --- a/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php +++ b/app/Http/Controllers/Api/Tenant/EventAddonCatalogController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; +use App\Models\CheckoutSession; use App\Services\Addons\EventAddonCatalog; use Illuminate\Http\JsonResponse; @@ -12,9 +13,25 @@ class EventAddonCatalogController extends Controller public function index(): JsonResponse { + $provider = config('package-addons.provider') + ?? config('checkout.default_provider', CheckoutSession::PROVIDER_PAYPAL); + $addons = collect($this->catalog->all()) - ->filter(fn (array $addon) => ! empty($addon['variant_id'])) - ->map(fn (array $addon, string $key) => array_merge($addon, ['key' => $key])) + ->map(function (array $addon, string $key) use ($provider): array { + $priceId = $provider === CheckoutSession::PROVIDER_PAYPAL + ? ($addon['price'] ?? null ? 'paypal' : null) + : ($addon['variant_id'] ?? null); + + return [ + 'key' => $key, + 'label' => $addon['label'] ?? null, + 'price_id' => $priceId, + 'increments' => $addon['increments'] ?? [], + 'price' => $addon['price'] ?? null, + 'currency' => $addon['currency'] ?? 'EUR', + ]; + }) + ->filter(fn (array $addon) => ! empty($addon['price_id'])) ->values() ->all(); diff --git a/app/Http/Controllers/PayPalAddonReturnController.php b/app/Http/Controllers/PayPalAddonReturnController.php new file mode 100644 index 00000000..32ed0d95 --- /dev/null +++ b/app/Http/Controllers/PayPalAddonReturnController.php @@ -0,0 +1,150 @@ +resolveOrderId($request); + $fallback = $this->resolveFallbackUrl(); + + if (! $orderId) { + return redirect()->to($fallback); + } + + $addon = EventPackageAddon::query() + ->where('checkout_id', $orderId) + ->first(); + + if (! $addon) { + return redirect()->to($fallback); + } + + $successUrl = Arr::get($addon->metadata ?? [], 'paypal_success_url') + ?? Arr::get($addon->metadata ?? [], 'success_url'); + $cancelUrl = Arr::get($addon->metadata ?? [], 'paypal_cancel_url') + ?? Arr::get($addon->metadata ?? [], 'cancel_url'); + + if ($addon->status === 'completed') { + return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback)); + } + + try { + $capture = $this->orders->captureOrder($orderId, [ + 'request_id' => 'addon-'.$addon->id, + ]); + } catch (PayPalException $exception) { + $this->addons->fail($addon, 'paypal_capture_failed', [ + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback)); + } + + $captureId = $this->resolveCaptureId($capture); + $totals = $this->resolveTotals($capture); + + $this->addons->complete( + $addon, + $capture, + $captureId, + $orderId, + $totals['total'] ?? null, + $totals['currency'] ?? null, + [ + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => $captureId, + 'paypal_status' => $capture['status'] ?? null, + 'paypal_totals' => $totals ?: null, + 'paypal_captured_at' => now()->toIso8601String(), + ], + ); + + return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback)); + } + + protected function resolveOrderId(Request $request): ?string + { + $candidate = $request->query('token') ?? $request->query('order_id'); + + if (! is_string($candidate) || $candidate === '') { + return null; + } + + return $candidate; + } + + protected function resolveFallbackUrl(): string + { + return rtrim((string) config('app.url', url('/')), '/') ?: url('/'); + } + + protected function resolveSafeRedirect(?string $target, string $fallback): string + { + if (! $target) { + return $fallback; + } + + if (Str::startsWith($target, ['/'])) { + return $target; + } + + $appHost = parse_url($fallback, PHP_URL_HOST); + $targetHost = parse_url($target, PHP_URL_HOST); + + if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) { + return $target; + } + + return $fallback; + } + + /** + * @param array $capture + */ + protected function resolveCaptureId(array $capture): ?string + { + $captureId = Arr::get($capture, 'purchase_units.0.payments.captures.0.id') + ?? Arr::get($capture, 'id'); + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + /** + * @param array $capture + * @return array{currency?: string, total?: float} + */ + protected function resolveTotals(array $capture): array + { + $amount = Arr::get($capture, 'purchase_units.0.payments.captures.0.amount') + ?? Arr::get($capture, 'purchase_units.0.amount'); + + if (! is_array($amount)) { + return []; + } + + $currency = Arr::get($amount, 'currency_code'); + $total = Arr::get($amount, 'value'); + + return array_filter([ + 'currency' => is_string($currency) ? strtoupper($currency) : null, + 'total' => is_numeric($total) ? (float) $total : null, + ], static fn ($value) => $value !== null); + } +} diff --git a/app/Http/Controllers/PayPalGiftVoucherReturnController.php b/app/Http/Controllers/PayPalGiftVoucherReturnController.php new file mode 100644 index 00000000..081a0042 --- /dev/null +++ b/app/Http/Controllers/PayPalGiftVoucherReturnController.php @@ -0,0 +1,129 @@ +resolveOrderId($request); + $fallback = $this->resolveFallbackUrl(); + + if (! $orderId) { + return redirect()->to($fallback); + } + + $voucher = GiftVoucher::query() + ->where('paypal_order_id', $orderId) + ->first(); + + if (! $voucher) { + return redirect()->to($fallback); + } + + $successUrl = Arr::get($voucher->metadata ?? [], 'paypal_success_url') + ?? Arr::get($voucher->metadata ?? [], 'success_url'); + $cancelUrl = Arr::get($voucher->metadata ?? [], 'paypal_cancel_url') + ?? Arr::get($voucher->metadata ?? [], 'return_url'); + + if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) { + return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback)); + } + + try { + $capture = $this->orders->captureOrder($orderId, [ + 'request_id' => 'gift-voucher-'.$voucher->id, + ]); + } catch (PayPalException $exception) { + return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback)); + } + + $this->vouchers->issueFromPayPal($voucher, $capture, $orderId); + + return redirect()->to($this->resolveSafeRedirect($this->appendOrderId($successUrl, $orderId), $fallback)); + } + + protected function resolveOrderId(Request $request): ?string + { + $candidate = $request->query('token') ?? $request->query('order_id'); + + if (! is_string($candidate) || $candidate === '') { + return null; + } + + return $candidate; + } + + protected function resolveFallbackUrl(): string + { + return rtrim((string) config('app.url', url('/')), '/') ?: url('/'); + } + + protected function resolveSafeRedirect(?string $target, string $fallback): string + { + if (! $target) { + return $fallback; + } + + if (Str::startsWith($target, ['/'])) { + return $target; + } + + $appHost = parse_url($fallback, PHP_URL_HOST); + $targetHost = parse_url($target, PHP_URL_HOST); + + if ($appHost && $targetHost && Str::lower($appHost) === Str::lower($targetHost)) { + return $target; + } + + return $fallback; + } + + protected function appendOrderId(?string $url, string $orderId): ?string + { + if (! $url) { + return null; + } + + $parts = parse_url($url); + if (! $parts) { + return $url; + } + + $query = []; + if (! empty($parts['query'])) { + parse_str($parts['query'], $query); + } + + if (! isset($query['order_id'])) { + $query['order_id'] = $orderId; + } + + $scheme = $parts['scheme'] ?? null; + $host = $parts['host'] ?? null; + $port = isset($parts['port']) ? ':'.$parts['port'] : ''; + $path = $parts['path'] ?? ''; + $fragment = isset($parts['fragment']) ? '#'.$parts['fragment'] : ''; + $queryString = $query ? '?'.http_build_query($query) : ''; + + if ($scheme && $host) { + return $scheme.'://'.$host.$port.$path.$queryString.$fragment; + } + + return $path.$queryString.$fragment; + } +} diff --git a/app/Http/Controllers/PayPalWebhookController.php b/app/Http/Controllers/PayPalWebhookController.php index fbb1cbce..77d79ad0 100644 --- a/app/Http/Controllers/PayPalWebhookController.php +++ b/app/Http/Controllers/PayPalWebhookController.php @@ -3,6 +3,8 @@ namespace App\Http\Controllers; use App\Services\Integrations\IntegrationWebhookRecorder; +use App\Services\PayPal\PayPalAddonWebhookService; +use App\Services\PayPal\PayPalGiftVoucherWebhookService; use App\Services\PayPal\PayPalWebhookService; use App\Services\PayPal\PayPalWebhookVerifier; use Illuminate\Http\JsonResponse; @@ -15,6 +17,8 @@ class PayPalWebhookController extends Controller public function __construct( private readonly PayPalWebhookVerifier $verifier, private readonly PayPalWebhookService $webhooks, + private readonly PayPalAddonWebhookService $addonWebhooks, + private readonly PayPalGiftVoucherWebhookService $giftVoucherWebhooks, private readonly IntegrationWebhookRecorder $recorder, ) {} @@ -42,7 +46,13 @@ class PayPalWebhookController extends Controller is_string($eventType) ? $eventType : null, ); - $handled = is_string($eventType) ? $this->webhooks->handle($payload) : false; + $handled = false; + + if (is_string($eventType)) { + $handled = $this->webhooks->handle($payload) || $handled; + $handled = $this->addonWebhooks->handle($payload) || $handled; + $handled = $this->giftVoucherWebhooks->handle($payload) || $handled; + } Log::info('PayPal webhook processed', [ 'event_type' => $eventType, diff --git a/app/Models/GiftVoucher.php b/app/Models/GiftVoucher.php index db45d184..83056a3c 100644 --- a/app/Models/GiftVoucher.php +++ b/app/Models/GiftVoucher.php @@ -15,6 +15,8 @@ class GiftVoucher extends Model use SoftDeletes; + public const STATUS_PENDING = 'pending'; + public const STATUS_ISSUED = 'issued'; public const STATUS_REDEEMED = 'redeemed'; @@ -35,6 +37,8 @@ class GiftVoucher extends Model 'lemonsqueezy_order_id', 'lemonsqueezy_checkout_id', 'lemonsqueezy_variant_id', + 'paypal_order_id', + 'paypal_capture_id', 'coupon_id', 'expires_at', 'redeemed_at', @@ -89,6 +93,10 @@ class GiftVoucher extends Model public function canBeRedeemed(): bool { + if ($this->status !== self::STATUS_ISSUED) { + return false; + } + return ! $this->isRedeemed() && ! $this->isRefunded() && ! $this->isExpired(); } diff --git a/app/Services/Addons/EventAddonCatalog.php b/app/Services/Addons/EventAddonCatalog.php index 53c839bb..476af0f9 100644 --- a/app/Services/Addons/EventAddonCatalog.php +++ b/app/Services/Addons/EventAddonCatalog.php @@ -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 */ diff --git a/app/Services/Addons/EventAddonCheckoutService.php b/app/Services/Addons/EventAddonCheckoutService.php index e59b6077..1b1c601b 100644 --- a/app/Services/Addons/EventAddonCheckoutService.php +++ b/app/Services/Addons/EventAddonCheckoutService.php @@ -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()); diff --git a/app/Services/Addons/EventAddonPurchaseService.php b/app/Services/Addons/EventAddonPurchaseService.php new file mode 100644 index 00000000..d3257fee --- /dev/null +++ b/app/Services/Addons/EventAddonPurchaseService.php @@ -0,0 +1,117 @@ + $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 $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 + */ + 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); + } +} diff --git a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php index 9da67516..37ba8fd8 100644 --- a/app/Services/GiftVouchers/GiftVoucherCheckoutService.php +++ b/app/Services/GiftVouchers/GiftVoucherCheckoutService.php @@ -2,24 +2,37 @@ namespace App\Services\GiftVouchers; +use App\Models\CheckoutSession; +use App\Models\GiftVoucher; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; +use App\Services\PayPal\Exceptions\PayPalException; +use App\Services\PayPal\PayPalOrderService; use Illuminate\Support\Facades\App; use Illuminate\Support\Str; use Illuminate\Validation\ValidationException; class GiftVoucherCheckoutService { - public function __construct(private readonly LemonSqueezyCheckoutService $checkout) {} + public function __construct( + private readonly LemonSqueezyCheckoutService $checkout, + private readonly PayPalOrderService $paypalOrders, + ) {} /** * @return array */ public function tiers(): array { + $provider = $this->resolveProvider(); + $checkoutCurrency = Str::upper((string) config('checkout.currency', 'EUR')); + return collect(config('gift-vouchers.tiers', [])) - ->map(function (array $tier): array { + ->map(function (array $tier) use ($provider, $checkoutCurrency): array { $currency = Str::upper($tier['currency'] ?? 'EUR'); $variantId = $tier['lemonsqueezy_variant_id'] ?? null; + $canCheckout = $provider === CheckoutSession::PROVIDER_PAYPAL + ? $currency === $checkoutCurrency + : ! empty($variantId); return [ 'key' => $tier['key'], @@ -27,7 +40,7 @@ class GiftVoucherCheckoutService 'amount' => (float) $tier['amount'], 'currency' => $currency, 'lemonsqueezy_variant_id' => $variantId, - 'can_checkout' => ! empty($variantId), + 'can_checkout' => $canCheckout, ]; }) ->values() @@ -40,6 +53,12 @@ class GiftVoucherCheckoutService */ public function create(array $data): array { + $provider = $this->resolveProvider(); + + if ($provider === CheckoutSession::PROVIDER_PAYPAL) { + return $this->createPayPalCheckout($data); + } + $tier = $this->findTier($data['tier_key']); if (! $tier || empty($tier['lemonsqueezy_variant_id'])) { @@ -90,4 +109,119 @@ class GiftVoucherCheckoutService return $tier; } + + /** + * @param array{tier_key:string,purchaser_email:string,recipient_email?:string|null,recipient_name?:string|null,message?:string|null,success_url?:string|null,return_url?:string|null} $data + * @return array{checkout_url:?string,expires_at:?string,id:?string} + */ + protected function createPayPalCheckout(array $data): array + { + $tier = $this->findTier($data['tier_key']); + + if (! $tier) { + throw ValidationException::withMessages([ + 'tier_key' => __('Gift voucher is not available right now.'), + ]); + } + + $currency = Str::upper($tier['currency'] ?? 'EUR'); + $checkoutCurrency = Str::upper((string) config('checkout.currency', 'EUR')); + + if ($currency !== $checkoutCurrency) { + throw ValidationException::withMessages([ + 'tier_key' => __('Gift voucher currency is not supported.'), + ]); + } + + $voucher = GiftVoucher::create([ + 'code' => $this->generateCode(), + 'amount' => (float) $tier['amount'], + 'currency' => $currency, + 'status' => GiftVoucher::STATUS_PENDING, + 'purchaser_email' => $data['purchaser_email'], + 'recipient_email' => $data['recipient_email'] ?? null, + 'recipient_name' => $data['recipient_name'] ?? null, + 'message' => $data['message'] ?? null, + 'metadata' => array_filter([ + 'tier_key' => $tier['key'], + 'app_locale' => App::getLocale(), + 'success_url' => $data['success_url'] ?? null, + 'return_url' => $data['return_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''), + 'expires_at' => now()->addYears((int) config('gift-vouchers.default_valid_years', 5)), + ]); + + $successUrl = $data['success_url'] ?? null; + $returnUrl = $data['return_url'] ?? $successUrl; + $paypalReturnUrl = route('paypal.gift-voucher.return', absolute: true); + + try { + $order = $this->paypalOrders->createSimpleOrder( + referenceId: 'gift-voucher-'.$voucher->id, + description: $tier['label'] ?? 'Gift Voucher', + amount: (float) $tier['amount'], + currency: $currency, + options: [ + 'custom_id' => 'gift_voucher_'.$voucher->id, + 'return_url' => $paypalReturnUrl, + 'cancel_url' => $paypalReturnUrl, + 'locale' => App::getLocale(), + 'request_id' => 'gift-voucher-'.$voucher->id, + ], + ); + } catch (PayPalException) { + $voucher->delete(); + + throw ValidationException::withMessages([ + 'tier_key' => __('Unable to create PayPal checkout.'), + ]); + } + + $orderId = $order['id'] ?? null; + + if (! is_string($orderId) || $orderId === '') { + $voucher->delete(); + + throw ValidationException::withMessages([ + 'tier_key' => __('PayPal order ID missing.'), + ]); + } + + $approveUrl = $this->paypalOrders->resolveApproveUrl($order); + + $voucher->forceFill([ + 'paypal_order_id' => $orderId, + 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ + 'paypal_order_id' => $orderId, + 'paypal_approve_url' => $approveUrl, + 'paypal_success_url' => $successUrl, + 'paypal_cancel_url' => $returnUrl, + 'paypal_created_at' => now()->toIso8601String(), + ])), + ])->save(); + + return [ + 'checkout_url' => $approveUrl, + 'expires_at' => null, + 'id' => $orderId, + ]; + } + + protected function resolveProvider(): ?string + { + $provider = config('gift-vouchers.provider'); + + if (is_string($provider) && $provider !== '') { + return $provider; + } + + $default = config('checkout.default_provider'); + + return is_string($default) && $default !== '' ? $default : null; + } + + protected function generateCode(): string + { + return 'GIFT-'.Str::upper(Str::random(8)); + } } diff --git a/app/Services/GiftVouchers/GiftVoucherService.php b/app/Services/GiftVouchers/GiftVoucherService.php index 24f288bf..dd1b6b65 100644 --- a/app/Services/GiftVouchers/GiftVoucherService.php +++ b/app/Services/GiftVouchers/GiftVoucherService.php @@ -81,6 +81,48 @@ class GiftVoucherService return $voucher; } + /** + * Create or finalize a voucher from a PayPal capture payload. + * + * @param array $payload + */ + public function issueFromPayPal(GiftVoucher $voucher, array $payload, ?string $orderId = null): GiftVoucher + { + if (in_array($voucher->status, [GiftVoucher::STATUS_ISSUED, GiftVoucher::STATUS_REDEEMED], true)) { + return $voucher; + } + + $captureId = Arr::get($payload, 'purchase_units.0.payments.captures.0.id') + ?? Arr::get($payload, 'id'); + + $locale = Arr::get($voucher->metadata ?? [], 'app_locale') ?? app()->getLocale(); + + $voucher->forceFill([ + 'status' => GiftVoucher::STATUS_ISSUED, + 'paypal_order_id' => $orderId ?? $voucher->paypal_order_id, + 'paypal_capture_id' => is_string($captureId) ? $captureId : $voucher->paypal_capture_id, + 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => is_string($captureId) ? $captureId : null, + 'paypal_status' => $payload['status'] ?? null, + 'paypal_captured_at' => now()->toIso8601String(), + ])), + ])->save(); + + if (! $voucher->coupon_id) { + $coupon = $this->createCouponForVoucher($voucher); + $voucher->forceFill(['coupon_id' => $coupon->id])->save(); + } + + $notificationsSent = (bool) Arr::get($voucher->metadata ?? [], 'notifications_sent', false); + + if (! $notificationsSent) { + $this->sendNotifications($voucher, locale: $locale); + } + + return $voucher; + } + public function resend(GiftVoucher $voucher, ?string $locale = null, ?bool $recipientOnly = null): void { $this->sendNotifications($voucher, force: true, locale: $locale, recipientOnly: $recipientOnly); @@ -152,6 +194,31 @@ class GiftVoucherService return $response; } + /** + * @param array $payload + */ + public function markRefundedFromPayPal(GiftVoucher $voucher, array $payload = []): void + { + if ($voucher->isRefunded()) { + return; + } + + $voucher->forceFill([ + 'status' => GiftVoucher::STATUS_REFUNDED, + 'refunded_at' => now(), + 'metadata' => array_merge($voucher->metadata ?? [], array_filter([ + 'paypal_refund_payload' => $payload ?: null, + ])), + ])->save(); + + if ($voucher->coupon) { + $voucher->coupon->forceFill([ + 'status' => CouponStatus::ARCHIVED, + 'enabled_for_checkout' => false, + ])->save(); + } + } + protected function createCouponForVoucher(GiftVoucher $voucher): Coupon { $packages = $this->eligiblePackages(); diff --git a/app/Services/PayPal/PayPalAddonWebhookService.php b/app/Services/PayPal/PayPalAddonWebhookService.php new file mode 100644 index 00000000..7230aefc --- /dev/null +++ b/app/Services/PayPal/PayPalAddonWebhookService.php @@ -0,0 +1,166 @@ + $event + */ + public function handle(array $event): bool + { + $eventType = $event['event_type'] ?? null; + $resource = $event['resource'] ?? null; + + if (! is_string($eventType) || ! is_array($resource)) { + return false; + } + + $normalized = strtoupper($eventType); + + if (! in_array($normalized, [ + 'PAYMENT.CAPTURE.COMPLETED', + 'PAYMENT.CAPTURE.PENDING', + 'PAYMENT.CAPTURE.DENIED', + 'PAYMENT.CAPTURE.REFUNDED', + 'CHECKOUT.ORDER.COMPLETED', + 'CHECKOUT.ORDER.VOIDED', + ], true)) { + return false; + } + + $orderId = $this->resolveOrderId($normalized, $resource); + + if (! $orderId) { + return false; + } + + $addon = EventPackageAddon::query() + ->where('checkout_id', $orderId) + ->first(); + + if (! $addon) { + return false; + } + + $lock = Cache::lock('addon:webhook:paypal:'.$orderId, 30); + + if (! $lock->get()) { + Log::info('[PayPalAddonWebhook] lock busy', [ + 'order_id' => $orderId, + 'addon_id' => $addon->id, + ]); + + return true; + } + + try { + if ($addon->status === 'completed' && $normalized === 'PAYMENT.CAPTURE.COMPLETED') { + return true; + } + + if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) { + $captureId = $this->resolveCaptureId($resource, $normalized); + $totals = $this->resolveTotals($resource); + + $this->addons->complete( + $addon, + $resource, + $captureId, + $orderId, + $totals['total'] ?? null, + $totals['currency'] ?? null, + [ + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => $captureId, + 'paypal_status' => $resource['status'] ?? null, + 'paypal_totals' => $totals ?: null, + 'paypal_captured_at' => now()->toIso8601String(), + ], + ); + + return true; + } + + if (in_array($normalized, ['PAYMENT.CAPTURE.DENIED', 'PAYMENT.CAPTURE.REFUNDED', 'CHECKOUT.ORDER.VOIDED'], true)) { + $reason = match ($normalized) { + 'PAYMENT.CAPTURE.DENIED' => 'paypal_capture_denied', + 'PAYMENT.CAPTURE.REFUNDED' => 'paypal_refunded', + 'CHECKOUT.ORDER.VOIDED' => 'paypal_voided', + default => 'paypal_failed', + }; + + $this->addons->fail($addon, $reason, $resource); + + return true; + } + + return false; + } finally { + $lock->release(); + } + } + + /** + * @param array $resource + */ + protected function resolveOrderId(string $eventType, array $resource): ?string + { + if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { + $relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id'); + + return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null; + } + + $orderId = $resource['id'] ?? null; + + return is_string($orderId) && $orderId !== '' ? $orderId : null; + } + + /** + * @param array $resource + */ + protected function resolveCaptureId(array $resource, string $eventType): ?string + { + if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { + $captureId = $resource['id'] ?? null; + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + $captureId = Arr::get($resource, 'purchase_units.0.payments.captures.0.id'); + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + /** + * @param array $resource + * @return array{currency?: string, total?: float} + */ + protected function resolveTotals(array $resource): array + { + $amount = Arr::get($resource, 'amount') + ?? Arr::get($resource, 'purchase_units.0.payments.captures.0.amount') + ?? Arr::get($resource, 'purchase_units.0.amount'); + + if (! is_array($amount)) { + return []; + } + + $currency = Arr::get($amount, 'currency_code'); + $total = Arr::get($amount, 'value'); + + return array_filter([ + 'currency' => is_string($currency) ? strtoupper($currency) : null, + 'total' => is_numeric($total) ? (float) $total : null, + ], static fn ($value) => $value !== null); + } +} diff --git a/app/Services/PayPal/PayPalGiftVoucherWebhookService.php b/app/Services/PayPal/PayPalGiftVoucherWebhookService.php new file mode 100644 index 00000000..f2c832e1 --- /dev/null +++ b/app/Services/PayPal/PayPalGiftVoucherWebhookService.php @@ -0,0 +1,79 @@ + $event + */ + public function handle(array $event): bool + { + $eventType = $event['event_type'] ?? null; + $resource = $event['resource'] ?? null; + + if (! is_string($eventType) || ! is_array($resource)) { + return false; + } + + $normalized = strtoupper($eventType); + + if (! in_array($normalized, [ + 'PAYMENT.CAPTURE.COMPLETED', + 'PAYMENT.CAPTURE.REFUNDED', + 'CHECKOUT.ORDER.COMPLETED', + ], true)) { + return false; + } + + $orderId = $this->resolveOrderId($normalized, $resource); + + if (! $orderId) { + return false; + } + + $voucher = GiftVoucher::query() + ->where('paypal_order_id', $orderId) + ->first(); + + if (! $voucher) { + return false; + } + + if (in_array($normalized, ['PAYMENT.CAPTURE.COMPLETED', 'CHECKOUT.ORDER.COMPLETED'], true)) { + $this->vouchers->issueFromPayPal($voucher, $resource, $orderId); + + return true; + } + + if ($normalized === 'PAYMENT.CAPTURE.REFUNDED') { + $this->vouchers->markRefundedFromPayPal($voucher, $resource); + + return true; + } + + return false; + } + + /** + * @param array $resource + */ + protected function resolveOrderId(string $eventType, array $resource): ?string + { + if (str_starts_with($eventType, 'PAYMENT.CAPTURE.')) { + $relatedOrderId = Arr::get($resource, 'supplementary_data.related_ids.order_id'); + + return is_string($relatedOrderId) && $relatedOrderId !== '' ? $relatedOrderId : null; + } + + $orderId = $resource['id'] ?? null; + + return is_string($orderId) && $orderId !== '' ? $orderId : null; + } +} diff --git a/app/Services/PayPal/PayPalOrderService.php b/app/Services/PayPal/PayPalOrderService.php index c3d6660c..8cdec694 100644 --- a/app/Services/PayPal/PayPalOrderService.php +++ b/app/Services/PayPal/PayPalOrderService.php @@ -72,6 +72,61 @@ class PayPalOrderService return $this->client->post('/v2/checkout/orders', $payload, $headers); } + /** + * @param array{ + * custom_id?: string|null, + * return_url?: string|null, + * cancel_url?: string|null, + * locale?: string|null, + * request_id?: string|null + * } $options + * @return array + */ + public function createSimpleOrder( + string $referenceId, + string $description, + float $amount, + string $currency, + array $options = [], + ): array { + $formattedAmount = $this->formatAmount($amount); + $currency = strtoupper($currency); + + $purchaseUnit = array_filter([ + 'reference_id' => Str::limit($referenceId, 127, ''), + 'description' => Str::limit($description, 127, ''), + 'custom_id' => $options['custom_id'] ?? null, + 'amount' => [ + 'currency_code' => $currency, + 'value' => $formattedAmount, + ], + ], static fn ($value) => $value !== null && $value !== ''); + + $applicationContext = array_filter([ + 'brand_name' => config('app.name', 'Fotospiel'), + 'landing_page' => 'NO_PREFERENCE', + 'user_action' => 'PAY_NOW', + 'shipping_preference' => 'NO_SHIPPING', + 'locale' => $this->resolveLocale($options['locale'] ?? null), + 'return_url' => $options['return_url'] ?? null, + 'cancel_url' => $options['cancel_url'] ?? null, + ], static fn ($value) => $value !== null && $value !== ''); + + $payload = [ + 'intent' => 'CAPTURE', + 'purchase_units' => [$purchaseUnit], + 'application_context' => $applicationContext, + ]; + + $headers = []; + $requestId = $options['request_id'] ?? null; + if (is_string($requestId) && $requestId !== '') { + $headers['PayPal-Request-Id'] = $requestId; + } + + return $this->client->post('/v2/checkout/orders', $payload, $headers); + } + /** * @param array{request_id?: string|null} $options * @return array diff --git a/config/gift-vouchers.php b/config/gift-vouchers.php index 742c49db..ce392f19 100644 --- a/config/gift-vouchers.php +++ b/config/gift-vouchers.php @@ -1,6 +1,7 @@ env('GIFT_VOUCHER_PROVIDER', env('CHECKOUT_DEFAULT_PROVIDER', 'paypal')), 'default_valid_years' => 5, 'reminder_days' => 7, 'expiry_reminder_days' => 14, diff --git a/config/package-addons.php b/config/package-addons.php index 3e52e95c..cbd0e1bc 100644 --- a/config/package-addons.php +++ b/config/package-addons.php @@ -1,6 +1,8 @@ env('ADDON_CHECKOUT_PROVIDER', env('CHECKOUT_DEFAULT_PROVIDER', 'paypal')), + // Keyed add-ons with display and Lemon Squeezy mapping. Amounts are base increments; multiply by quantity. 'extra_photos_small' => [ 'label' => 'Extra photos (500)', diff --git a/database/migrations/2026_02_04_143507_add_paypal_columns_to_gift_vouchers_table.php b/database/migrations/2026_02_04_143507_add_paypal_columns_to_gift_vouchers_table.php new file mode 100644 index 00000000..afb0e391 --- /dev/null +++ b/database/migrations/2026_02_04_143507_add_paypal_columns_to_gift_vouchers_table.php @@ -0,0 +1,40 @@ +string('paypal_order_id')->nullable()->after('lemonsqueezy_variant_id'); + } + + if (! Schema::hasColumn('gift_vouchers', 'paypal_capture_id')) { + $table->string('paypal_capture_id')->nullable()->after('paypal_order_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('gift_vouchers', function (Blueprint $table) { + if (Schema::hasColumn('gift_vouchers', 'paypal_capture_id')) { + $table->dropColumn('paypal_capture_id'); + } + + if (Schema::hasColumn('gift_vouchers', 'paypal_order_id')) { + $table->dropColumn('paypal_order_id'); + } + }); + } +}; diff --git a/routes/web.php b/routes/web.php index 1305320a..abb9879f 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,7 +13,9 @@ use App\Http\Controllers\LemonSqueezyWebhookController; use App\Http\Controllers\LocaleController; use App\Http\Controllers\Marketing\GiftVoucherPrintController; use App\Http\Controllers\MarketingController; +use App\Http\Controllers\PayPalAddonReturnController; use App\Http\Controllers\PayPalCheckoutController; +use App\Http\Controllers\PayPalGiftVoucherReturnController; use App\Http\Controllers\PayPalReturnController; use App\Http\Controllers\PayPalWebhookController; use App\Http\Controllers\ProfileAccountController; @@ -437,5 +439,11 @@ Route::post('/paypal/webhook', [PayPalWebhookController::class, 'handle']) ->middleware('throttle:paypal-webhook') ->name('paypal.webhook'); +Route::get('/paypal/addon/return', PayPalAddonReturnController::class) + ->name('paypal.addon.return'); + +Route::get('/paypal/gift-voucher/return', PayPalGiftVoucherReturnController::class) + ->name('paypal.gift-voucher.return'); + Route::get('/paypal/return', PayPalReturnController::class) ->name('paypal.return'); diff --git a/tests/Feature/Api/GiftVoucherLookupTest.php b/tests/Feature/Api/GiftVoucherLookupTest.php index f3e2bb27..b1994464 100644 --- a/tests/Feature/Api/GiftVoucherLookupTest.php +++ b/tests/Feature/Api/GiftVoucherLookupTest.php @@ -28,6 +28,25 @@ class GiftVoucherLookupTest extends TestCase ->assertJsonPath('data.currency', 'EUR'); } + public function test_it_returns_voucher_by_paypal_order_id(): void + { + $voucher = GiftVoucher::factory()->create([ + 'code' => 'GIFT-PAYPAL', + 'amount' => 59.00, + 'currency' => 'EUR', + 'paypal_order_id' => 'ORDER-PAYPAL-1', + 'paypal_capture_id' => 'CAPTURE-PAYPAL-1', + 'status' => GiftVoucher::STATUS_ISSUED, + ]); + + $response = $this->getJson('/api/v1/marketing/gift-vouchers/lookup?order_id=CAPTURE-PAYPAL-1'); + + $response->assertOk() + ->assertJsonPath('data.code', $voucher->code) + ->assertJsonPath('data.amount', 59) + ->assertJsonPath('data.currency', 'EUR'); + } + public function test_it_returns_voucher_by_code(): void { $voucher = GiftVoucher::factory()->create([ diff --git a/tests/Feature/PayPalWebhookControllerTest.php b/tests/Feature/PayPalWebhookControllerTest.php index 172d4251..af0b4cdc 100644 --- a/tests/Feature/PayPalWebhookControllerTest.php +++ b/tests/Feature/PayPalWebhookControllerTest.php @@ -5,6 +5,10 @@ namespace Tests\Feature; use App\Models\CheckoutSession; use App\Models\Coupon; use App\Models\CouponRedemption; +use App\Models\Event; +use App\Models\EventPackage; +use App\Models\EventPackageAddon; +use App\Models\GiftVoucher; use App\Models\IntegrationWebhookEvent; use App\Models\Package; use App\Models\PackagePurchase; @@ -16,6 +20,8 @@ use App\Services\Coupons\CouponService; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Notification; use Tests\TestCase; class PayPalWebhookControllerTest extends TestCase @@ -140,6 +146,149 @@ class PayPalWebhookControllerTest extends TestCase $response->assertStatus(400)->assertJson(['status' => 'invalid']); } + public function test_capture_completed_applies_addon_purchase(): void + { + Notification::fake(); + + config([ + 'services.paypal.client_id' => 'client', + 'services.paypal.secret' => 'secret', + 'services.paypal.sandbox' => true, + 'services.paypal.webhook_id' => 'wh_123', + ]); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ + 'access_token' => 'token', + 'expires_in' => 3600, + ]), + 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ + 'verification_status' => 'SUCCESS', + ]), + ]); + + $tenant = Tenant::factory()->create([ + 'contact_email' => 'tenant@example.com', + ]); + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + ]); + $event = Event::factory()->for($tenant)->create([ + 'status' => 'published', + ]); + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $addon = EventPackageAddon::create([ + 'event_package_id' => $eventPackage->id, + 'event_id' => $event->id, + 'tenant_id' => $tenant->id, + 'addon_key' => 'extra_photos_small', + 'quantity' => 1, + 'extra_photos' => 0, + 'status' => 'pending', + 'checkout_id' => 'ORDER-ADDON-1', + 'metadata' => [ + 'increments' => ['extra_photos' => 500], + ], + ]); + + $payload = [ + 'id' => 'WH-ADDON-1', + 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', + 'resource' => [ + 'id' => 'CAPTURE-ADDON-1', + 'status' => 'COMPLETED', + 'amount' => [ + 'value' => '12.50', + 'currency_code' => 'EUR', + ], + 'supplementary_data' => [ + 'related_ids' => [ + 'order_id' => 'ORDER-ADDON-1', + ], + ], + ], + ]; + + $response = $this->withHeaders($this->paypalHeaders()) + ->postJson('/paypal/webhook', $payload); + + $response->assertOk()->assertJson(['status' => 'processed']); + + $addon->refresh(); + $eventPackage->refresh(); + + $this->assertSame('completed', $addon->status); + $this->assertSame('CAPTURE-ADDON-1', $addon->transaction_id); + $this->assertSame(500, $eventPackage->extra_photos); + } + + public function test_capture_completed_issues_gift_voucher(): void + { + Mail::fake(); + config()->set('gift-vouchers.reminder_days', 0); + config()->set('gift-vouchers.expiry_reminder_days', 0); + + config([ + 'services.paypal.client_id' => 'client', + 'services.paypal.secret' => 'secret', + 'services.paypal.sandbox' => true, + 'services.paypal.webhook_id' => 'wh_123', + ]); + + Http::fake([ + 'https://api-m.sandbox.paypal.com/v1/oauth2/token' => Http::response([ + 'access_token' => 'token', + 'expires_in' => 3600, + ]), + 'https://api-m.sandbox.paypal.com/v1/notifications/verify-webhook-signature' => Http::response([ + 'verification_status' => 'SUCCESS', + ]), + ]); + + $voucher = GiftVoucher::factory()->create([ + 'status' => GiftVoucher::STATUS_PENDING, + 'paypal_order_id' => 'ORDER-GIFT-1', + ]); + + $payload = [ + 'id' => 'WH-GIFT-1', + 'event_type' => 'PAYMENT.CAPTURE.COMPLETED', + 'resource' => [ + 'id' => 'CAPTURE-GIFT-1', + 'status' => 'COMPLETED', + 'amount' => [ + 'value' => '29.00', + 'currency_code' => 'EUR', + ], + 'supplementary_data' => [ + 'related_ids' => [ + 'order_id' => 'ORDER-GIFT-1', + ], + ], + ], + ]; + + $response = $this->withHeaders($this->paypalHeaders()) + ->postJson('/paypal/webhook', $payload); + + $response->assertOk()->assertJson(['status' => 'processed']); + + $voucher->refresh(); + + $this->assertSame(GiftVoucher::STATUS_ISSUED, $voucher->status); + $this->assertSame('CAPTURE-GIFT-1', $voucher->paypal_capture_id); + $this->assertNotNull($voucher->coupon_id); + } + /** * @return array{ * 0: \App\Models\Tenant, diff --git a/tests/Feature/Tenant/EventAddonCheckoutTest.php b/tests/Feature/Tenant/EventAddonCheckoutTest.php index 5ccfeed6..4306c3ea 100644 --- a/tests/Feature/Tenant/EventAddonCheckoutTest.php +++ b/tests/Feature/Tenant/EventAddonCheckoutTest.php @@ -2,12 +2,15 @@ namespace Tests\Feature\Tenant; +use App\Models\CheckoutSession; use App\Models\Event; use App\Models\EventPackage; use App\Models\EventPackageAddon; use App\Models\Package; +use App\Services\PayPal\PayPalOrderService; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Http; +use Mockery; class EventAddonCheckoutTest extends TenantTestCase { @@ -20,14 +23,15 @@ class EventAddonCheckoutTest extends TenantTestCase 'variant_id' => 'var_addon_photos', 'increments' => ['extra_photos' => 500], ]); + Config::set('package-addons.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY); Config::set('lemonsqueezy.api_key', 'test_key'); - Config::set('lemonsqueezy.base_url', 'https://lemonsqueezy.test'); + Config::set('lemonsqueezy.base_url', 'https://api.lemonsqueezy.com/v1'); Config::set('lemonsqueezy.store_id', 'store_123'); // Fake Lemon Squeezy response Http::fake([ - 'https://lemonsqueezy.test/checkouts' => Http::response([ + 'https://api.lemonsqueezy.com/v1/checkouts' => Http::response([ 'data' => [ 'id' => 'chk_addon_123', 'attributes' => [ @@ -81,4 +85,69 @@ class EventAddonCheckoutTest extends TenantTestCase $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); $this->assertSame(1000, $addon->extra_photos); // increments * quantity } + + public function test_paypal_checkout_creates_pending_addon_record(): void + { + Config::set('package-addons.provider', CheckoutSession::PROVIDER_PAYPAL); + Config::set('checkout.currency', 'EUR'); + Config::set('package-addons.extra_photos_small.price', 12.50); + + $package = Package::factory()->endcustomer()->create([ + 'max_photos' => 100, + 'max_guests' => 50, + 'gallery_days' => 7, + ]); + + $event = Event::factory()->for($this->tenant)->create([ + 'status' => 'published', + ]); + + $eventPackage = EventPackage::create([ + 'event_id' => $event->id, + 'package_id' => $package->id, + 'purchased_price' => $package->price, + 'purchased_at' => now(), + 'used_photos' => 0, + 'used_guests' => 0, + 'gallery_expires_at' => now()->addDays(7), + ]); + + $orders = Mockery::mock(PayPalOrderService::class); + $orders->shouldReceive('createSimpleOrder') + ->once() + ->andReturn([ + 'id' => 'ORDER-ADDON-1', + 'links' => [ + ['rel' => 'approve', 'href' => 'https://paypal.test/approve'], + ], + ]); + $orders->shouldReceive('resolveApproveUrl') + ->once() + ->andReturn('https://paypal.test/approve'); + $this->app->instance(PayPalOrderService::class, $orders); + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/addons/checkout", [ + 'addon_key' => 'extra_photos_small', + 'quantity' => 2, + 'accepted_terms' => true, + 'accepted_waiver' => true, + ]); + + $response->assertOk(); + $response->assertJsonPath('checkout_id', 'ORDER-ADDON-1'); + $response->assertJsonPath('checkout_url', 'https://paypal.test/approve'); + + $this->assertDatabaseHas('event_package_addons', [ + 'event_package_id' => $eventPackage->id, + 'addon_key' => 'extra_photos_small', + 'status' => 'pending', + 'quantity' => 2, + 'checkout_id' => 'ORDER-ADDON-1', + 'amount' => 25.00, + 'currency' => 'EUR', + ]); + + $addon = EventPackageAddon::where('event_package_id', $eventPackage->id)->latest()->first(); + $this->assertSame(1000, $addon->extra_photos); + } } diff --git a/tests/Unit/GiftVoucherCheckoutServiceTest.php b/tests/Unit/GiftVoucherCheckoutServiceTest.php index b614bfcd..1fe6f7c8 100644 --- a/tests/Unit/GiftVoucherCheckoutServiceTest.php +++ b/tests/Unit/GiftVoucherCheckoutServiceTest.php @@ -2,8 +2,10 @@ namespace Tests\Unit; +use App\Models\CheckoutSession; use App\Services\GiftVouchers\GiftVoucherCheckoutService; use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; +use App\Services\PayPal\PayPalOrderService; use Illuminate\Foundation\Testing\RefreshDatabase; use Mockery; use Tests\TestCase; @@ -14,6 +16,7 @@ class GiftVoucherCheckoutServiceTest extends TestCase public function test_it_lists_tiers_with_checkout_flag(): void { + config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY); config()->set('gift-vouchers.tiers', [ ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'], ['key' => 'gift-b', 'label' => 'B', 'amount' => 20, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null], @@ -28,8 +31,27 @@ class GiftVoucherCheckoutServiceTest extends TestCase $this->assertFalse($tiers[1]['can_checkout']); } + public function test_it_lists_tiers_for_paypal_currency_only(): void + { + config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_PAYPAL); + config()->set('checkout.currency', 'EUR'); + config()->set('gift-vouchers.tiers', [ + ['key' => 'gift-eur', 'label' => 'EUR', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null], + ['key' => 'gift-usd', 'label' => 'USD', 'amount' => 20, 'currency' => 'USD', 'lemonsqueezy_variant_id' => null], + ]); + + $service = $this->app->make(GiftVoucherCheckoutService::class); + + $tiers = $service->tiers(); + + $this->assertCount(2, $tiers); + $this->assertTrue($tiers[0]['can_checkout']); + $this->assertFalse($tiers[1]['can_checkout']); + } + public function test_it_creates_checkout_link_with_metadata(): void { + config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_LEMONSQUEEZY); config()->set('gift-vouchers.tiers', [ ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => 'pri_a'], ]); @@ -62,4 +84,46 @@ class GiftVoucherCheckoutServiceTest extends TestCase $this->assertSame('https://lemonsqueezy.test/checkout/123', $checkout['checkout_url']); $this->assertSame('chk_123', $checkout['id']); } + + public function test_it_creates_paypal_checkout(): void + { + config()->set('gift-vouchers.provider', CheckoutSession::PROVIDER_PAYPAL); + config()->set('checkout.currency', 'EUR'); + config()->set('gift-vouchers.tiers', [ + ['key' => 'gift-a', 'label' => 'A', 'amount' => 10, 'currency' => 'EUR', 'lemonsqueezy_variant_id' => null], + ]); + + $orders = Mockery::mock(PayPalOrderService::class); + $orders->shouldReceive('createSimpleOrder') + ->once() + ->andReturn([ + 'id' => 'ORDER-123', + 'links' => [ + ['rel' => 'approve', 'href' => 'https://paypal.test/approve'], + ], + ]); + $orders->shouldReceive('resolveApproveUrl') + ->once() + ->andReturn('https://paypal.test/approve'); + + $this->app->instance(PayPalOrderService::class, $orders); + + $service = $this->app->make(GiftVoucherCheckoutService::class); + + $checkout = $service->create([ + 'tier_key' => 'gift-a', + 'purchaser_email' => 'buyer@example.com', + 'recipient_email' => 'friend@example.com', + 'recipient_name' => 'Friend', + 'message' => 'Hi', + ]); + + $this->assertSame('https://paypal.test/approve', $checkout['checkout_url']); + $this->assertSame('ORDER-123', $checkout['id']); + + $this->assertDatabaseHas('gift_vouchers', [ + 'paypal_order_id' => 'ORDER-123', + 'status' => \App\Models\GiftVoucher::STATUS_PENDING, + ]); + } } diff --git a/tests/Unit/GiftVoucherServiceTest.php b/tests/Unit/GiftVoucherServiceTest.php index b2091c94..fcbfb166 100644 --- a/tests/Unit/GiftVoucherServiceTest.php +++ b/tests/Unit/GiftVoucherServiceTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit; +use App\Enums\CouponStatus; use App\Enums\CouponType; use App\Mail\GiftVoucherIssued; use App\Models\Coupon; @@ -156,4 +157,65 @@ class GiftVoucherServiceTest extends TestCase $this->assertSame(65.00, (float) $voucher->amount); $this->assertSame('USD', $voucher->currency); } + + public function test_it_issues_voucher_from_paypal_payload(): void + { + Mail::fake(); + config()->set('gift-vouchers.reminder_days', 0); + config()->set('gift-vouchers.expiry_reminder_days', 0); + + $voucher = GiftVoucher::factory()->create([ + 'status' => GiftVoucher::STATUS_PENDING, + 'paypal_order_id' => 'ORDER-123', + ]); + + $payload = [ + 'id' => 'CAPTURE-123', + 'status' => 'COMPLETED', + 'purchase_units' => [ + [ + 'payments' => [ + 'captures' => [ + ['id' => 'CAPTURE-123'], + ], + ], + ], + ], + ]; + + $service = $this->app->make(GiftVoucherService::class); + $service->issueFromPayPal($voucher, $payload, 'ORDER-123'); + + $voucher->refresh(); + + $this->assertSame(GiftVoucher::STATUS_ISSUED, $voucher->status); + $this->assertSame('ORDER-123', $voucher->paypal_order_id); + $this->assertSame('CAPTURE-123', $voucher->paypal_capture_id); + $this->assertNotNull($voucher->coupon_id); + + Mail::assertQueued(GiftVoucherIssued::class, 2); + } + + public function test_it_marks_voucher_refunded_from_paypal(): void + { + $coupon = Coupon::factory()->create([ + 'status' => CouponStatus::ACTIVE, + 'enabled_for_checkout' => true, + ]); + + $voucher = GiftVoucher::factory()->create([ + 'status' => GiftVoucher::STATUS_ISSUED, + 'coupon_id' => $coupon->id, + ]); + + $service = $this->app->make(GiftVoucherService::class); + $service->markRefundedFromPayPal($voucher, ['id' => 'REFUND-1']); + + $voucher->refresh(); + $coupon->refresh(); + + $this->assertSame(GiftVoucher::STATUS_REFUNDED, $voucher->status); + $this->assertSame(CouponStatus::ARCHIVED, $coupon->status); + $this->assertFalse($coupon->enabled_for_checkout); + } } diff --git a/tests/Unit/Services/EventAddonCatalogTest.php b/tests/Unit/Services/EventAddonCatalogTest.php index c0684364..c8d511d7 100644 --- a/tests/Unit/Services/EventAddonCatalogTest.php +++ b/tests/Unit/Services/EventAddonCatalogTest.php @@ -17,7 +17,7 @@ class EventAddonCatalogTest extends TestCase Config::set('package-addons', [ 'extra_photos_small' => [ 'label' => 'Config Photos', - 'price_id' => 'pri_config', + 'variant_id' => 'var_config', 'increments' => ['extra_photos' => 100], ], ]); @@ -25,10 +25,13 @@ class EventAddonCatalogTest extends TestCase PackageAddon::create([ 'key' => 'extra_photos_small', 'label' => 'DB Photos', - 'price_id' => 'pri_db', + 'variant_id' => 'var_db', 'extra_photos' => 200, 'active' => true, 'sort' => 1, + 'metadata' => [ + 'price_eur' => 12, + ], ]); $catalog = $this->app->make(EventAddonCatalog::class); @@ -37,7 +40,9 @@ class EventAddonCatalogTest extends TestCase $this->assertNotNull($addon); $this->assertSame('DB Photos', $addon['label']); - $this->assertSame('pri_db', $addon['price_id']); + $this->assertSame('var_db', $addon['variant_id']); $this->assertSame(200, $addon['increments']['extra_photos']); + $this->assertSame(12.0, $addon['price']); + $this->assertSame('EUR', $addon['currency']); } }