From fc5dfb272cb50a771d54c65d983cda751e331cec Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Wed, 4 Feb 2026 12:18:14 +0100 Subject: [PATCH] Add PayPal checkout provider --- .../Controllers/Api/PackageController.php | 133 +++-- app/Http/Controllers/CheckoutController.php | 8 +- app/Http/Controllers/MarketingController.php | 85 +-- .../Controllers/PayPalCheckoutController.php | 223 +++++++ .../Controllers/PayPalReturnController.php | 136 +++++ .../Requests/PayPal/PayPalCaptureRequest.php | 56 ++ .../Requests/PayPal/PayPalCheckoutRequest.php | 41 ++ app/Models/CheckoutSession.php | 2 + .../Checkout/CheckoutAssignmentService.php | 47 +- .../Checkout/CheckoutSessionService.php | 20 +- .../PayPal/Exceptions/PayPalException.php | 23 + app/Services/PayPal/PayPalClient.php | 145 +++++ app/Services/PayPal/PayPalOrderService.php | 161 +++++ config/checkout.php | 3 + ...pal_columns_to_checkout_sessions_table.php | 45 ++ public/lang/de/marketing.json | 48 +- public/lang/en/marketing.json | 48 +- resources/js/admin/api.ts | 10 +- .../admin/mobile/hooks/usePackageCheckout.ts | 11 +- .../js/admin/mobile/lib/billingCheckout.ts | 3 + .../js/pages/marketing/CheckoutWizardPage.tsx | 12 +- .../marketing/checkout/CheckoutWizard.tsx | 12 +- .../marketing/checkout/WizardContext.tsx | 22 +- .../__tests__/PaymentStep.render.test.tsx | 72 ++- .../marketing/checkout/steps/PaymentStep.tsx | 558 ++++++++---------- resources/lang/de/marketing.json | 33 +- resources/lang/de/marketing.php | 9 +- resources/lang/en/marketing.json | 33 +- resources/lang/en/marketing.php | 9 +- routes/api.php | 2 +- routes/web.php | 7 + .../Tenant/TenantLemonSqueezyCheckoutTest.php | 70 --- .../Tenant/TenantPayPalCheckoutTest.php | 70 +++ 33 files changed, 1586 insertions(+), 571 deletions(-) create mode 100644 app/Http/Controllers/PayPalCheckoutController.php create mode 100644 app/Http/Controllers/PayPalReturnController.php create mode 100644 app/Http/Requests/PayPal/PayPalCaptureRequest.php create mode 100644 app/Http/Requests/PayPal/PayPalCheckoutRequest.php create mode 100644 app/Services/PayPal/Exceptions/PayPalException.php create mode 100644 app/Services/PayPal/PayPalClient.php create mode 100644 app/Services/PayPal/PayPalOrderService.php create mode 100644 database/migrations/2026_02_04_114212_add_paypal_columns_to_checkout_sessions_table.php delete mode 100644 tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php create mode 100644 tests/Feature/Tenant/TenantPayPalCheckoutTest.php diff --git a/app/Http/Controllers/Api/PackageController.php b/app/Http/Controllers/Api/PackageController.php index 5bb09035..6997b51b 100644 --- a/app/Http/Controllers/Api/PackageController.php +++ b/app/Http/Controllers/Api/PackageController.php @@ -9,16 +9,18 @@ use App\Models\Package; use App\Models\PackagePurchase; use App\Models\TenantPackage; use App\Services\Checkout\CheckoutSessionService; -use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; +use App\Services\PayPal\Exceptions\PayPalException; +use App\Services\PayPal\PayPalOrderService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Validation\ValidationException; class PackageController extends Controller { public function __construct( - private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout, + private readonly PayPalOrderService $paypalOrders, private readonly CheckoutSessionService $sessions, ) {} @@ -53,7 +55,7 @@ class PackageController extends Controller $request->validate([ 'package_id' => 'required|exists:packages,id', 'type' => 'required|in:endcustomer,reseller', - 'payment_method' => 'required|in:lemonsqueezy', + 'payment_method' => 'required|in:paypal', 'event_id' => 'nullable|exists:events,id', // For endcustomer 'success_url' => 'nullable|url', 'return_url' => 'nullable|url', @@ -79,7 +81,7 @@ class PackageController extends Controller { $request->validate([ 'package_id' => 'required|exists:packages,id', - 'lemonsqueezy_order_id' => 'required|string', + 'paypal_order_id' => 'required|string', ]); $package = Package::findOrFail($request->package_id); @@ -89,14 +91,14 @@ class PackageController extends Controller throw ValidationException::withMessages(['tenant' => 'Tenant not found.']); } - $provider = 'lemonsqueezy'; + $provider = 'paypal'; DB::transaction(function () use ($request, $package, $tenant, $provider) { PackagePurchase::create([ 'tenant_id' => $tenant->id, 'package_id' => $package->id, 'provider' => $provider, - 'provider_id' => $request->input('lemonsqueezy_order_id'), + 'provider_id' => $request->input('paypal_order_id'), 'price' => $package->price, 'type' => 'endcustomer_event', 'purchased_at' => now(), @@ -161,12 +163,14 @@ class PackageController extends Controller ], 201); } - public function createLemonSqueezyCheckout(Request $request): JsonResponse + public function createPayPalCheckout(Request $request): JsonResponse { $request->validate([ 'package_id' => 'required|exists:packages,id', 'success_url' => 'nullable|url', 'return_url' => 'nullable|url', + 'cancel_url' => 'nullable|url', + 'locale' => 'nullable|string|max:10', ]); $package = Package::findOrFail($request->integer('package_id')); @@ -181,15 +185,11 @@ class PackageController extends Controller throw ValidationException::withMessages(['user' => 'User context missing.']); } - if (! $package->lemonsqueezy_variant_id) { - throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']); - } - $session = $this->sessions->createOrResume($user, $package, [ 'tenant' => $tenant, ]); - $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); $now = now(); @@ -201,30 +201,56 @@ class PackageController extends Controller 'legal_version' => config('app.legal_version', $now->toDateString()), ])->save(); - $payload = [ - 'success_url' => $request->input('success_url'), - 'return_url' => $request->input('return_url'), - 'metadata' => [ - 'checkout_session_id' => $session->id, - 'legal_version' => $session->legal_version, - 'accepted_terms' => true, - ], - ]; + $successUrl = $request->input('success_url') ?? $request->input('return_url'); + $cancelUrl = $request->input('cancel_url') ?? $request->input('return_url'); + $paypalReturnUrl = route('paypal.return', absolute: true); - $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, $payload); + try { + $order = $this->paypalOrders->createOrder($session, $package, [ + 'return_url' => $paypalReturnUrl, + 'cancel_url' => $paypalReturnUrl, + 'locale' => $request->input('locale'), + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal order creation failed (tenant)', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'session_id' => $session->id, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']); + } + + $orderId = $order['id'] ?? null; + if (! is_string($orderId) || $orderId === '') { + throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']); + } + + $approveUrl = $this->paypalOrders->resolveApproveUrl($order); $session->forceFill([ - 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id, + 'paypal_order_id' => $orderId, 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ - 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null, - 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null, - 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null, + 'paypal_order_id' => $orderId, + 'paypal_status' => $order['status'] ?? null, + 'paypal_approve_url' => $approveUrl, + 'paypal_success_url' => $successUrl, + 'paypal_cancel_url' => $cancelUrl, + 'paypal_created_at' => now()->toIso8601String(), ])), ])->save(); - return response()->json(array_merge($checkout, [ + $this->sessions->markRequiresCustomerAction($session, 'paypal_approval'); + + return response()->json([ + 'order_id' => $orderId, + 'approve_url' => $approveUrl, + 'status' => $order['status'] ?? null, 'checkout_session_id' => $session->id, - ])); + ]); } public function checkoutSessionStatus(CheckoutSessionStatusRequest $request, CheckoutSession $session): JsonResponse @@ -239,7 +265,9 @@ class PackageController extends Controller } } - $checkoutUrl = data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url'); + $checkoutUrl = $session->provider === CheckoutSession::PROVIDER_PAYPAL + ? data_get($session->provider_metadata ?? [], 'paypal_approve_url') + : data_get($session->provider_metadata ?? [], 'lemonsqueezy_checkout_url'); return response()->json([ 'status' => $session->status, @@ -297,19 +325,44 @@ class PackageController extends Controller private function handlePaidPurchase(Request $request, Package $package, $tenant): JsonResponse { - if (! $package->lemonsqueezy_variant_id) { - throw ValidationException::withMessages(['package_id' => 'Package is not linked to a Lemon Squeezy variant.']); + $successUrl = $request->input('success_url') ?? $request->input('return_url'); + $cancelUrl = $request->input('cancel_url') ?? $request->input('return_url'); + $paypalReturnUrl = route('paypal.return', absolute: true); + + try { + $session = $this->sessions->createOrResume($request->user(), $package, [ + 'tenant' => $tenant, + ]); + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); + + $order = $this->paypalOrders->createOrder($session, $package, [ + 'return_url' => $paypalReturnUrl, + 'cancel_url' => $paypalReturnUrl, + 'locale' => $request->input('locale'), + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal order creation failed (purchase)', [ + 'tenant_id' => $tenant->id, + 'package_id' => $package->id, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + throw ValidationException::withMessages(['paypal' => 'PayPal checkout could not be created.']); } - $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [ - 'success_url' => $request->input('success_url'), - 'return_url' => $request->input('return_url'), - 'metadata' => array_filter([ - 'type' => $request->input('type'), - 'event_id' => $request->input('event_id'), - ]), - ]); + $orderId = $order['id'] ?? null; + if (! is_string($orderId) || $orderId === '') { + throw ValidationException::withMessages(['paypal' => 'PayPal order ID missing.']); + } - return response()->json($checkout); + return response()->json([ + 'order_id' => $orderId, + 'approve_url' => $this->paypalOrders->resolveApproveUrl($order), + 'status' => $order['status'] ?? null, + 'return_url' => $successUrl, + 'cancel_url' => $cancelUrl, + ]); } } diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index c221bc64..11778c5b 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -74,9 +74,11 @@ class CheckoutController extends Controller 'error' => $facebookError, 'profile' => $facebookProfile, ], - 'lemonsqueezy' => [ - 'store_id' => config('lemonsqueezy.store_id'), - 'test_mode' => config('lemonsqueezy.test_mode', false), + 'paypal' => [ + 'client_id' => config('services.paypal.client_id'), + 'currency' => config('checkout.currency', 'EUR'), + 'intent' => 'capture', + 'locale' => app()->getLocale(), ], ]); } diff --git a/app/Http/Controllers/MarketingController.php b/app/Http/Controllers/MarketingController.php index 7a82ab50..55785715 100644 --- a/app/Http/Controllers/MarketingController.php +++ b/app/Http/Controllers/MarketingController.php @@ -13,7 +13,8 @@ use App\Models\TenantPackage; use App\Services\Checkout\CheckoutSessionService; use App\Services\Coupons\CouponService; use App\Services\GiftVouchers\GiftVoucherCheckoutService; -use App\Services\LemonSqueezy\LemonSqueezyCheckoutService; +use App\Services\PayPal\Exceptions\PayPalException; +use App\Services\PayPal\PayPalOrderService; use App\Support\CheckoutRequestContext; use App\Support\CheckoutRoutes; use App\Support\Concerns\PresentsPackages; @@ -41,7 +42,7 @@ class MarketingController extends Controller public function __construct( private readonly CheckoutSessionService $checkoutSessions, - private readonly LemonSqueezyCheckoutService $lemonsqueezyCheckout, + private readonly PayPalOrderService $paypalOrders, private readonly CouponService $coupons, private readonly GiftVoucherCheckoutService $giftVouchers, ) {} @@ -194,16 +195,6 @@ class MarketingController extends Controller return redirect('/event-admin')->with('success', __('marketing.packages.free_assigned')); } - if (! $package->lemonsqueezy_variant_id) { - Log::warning('Package missing Lemon Squeezy variant id', ['package_id' => $package->id]); - - return redirect()->route('packages', [ - 'locale' => app()->getLocale(), - 'highlight' => $package->slug, - ]) - ->with('error', __('marketing.packages.lemonsqueezy_not_configured')); - } - $session = $this->checkoutSessions->createOrResume($user, $package, array_merge( CheckoutRequestContext::fromRequest($request), [ @@ -211,7 +202,7 @@ class MarketingController extends Controller ] )); - $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_LEMONSQUEEZY); + $this->checkoutSessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); $now = now(); @@ -233,39 +224,61 @@ class MarketingController extends Controller } } - $checkout = $this->lemonsqueezyCheckout->createCheckout($tenant, $package, [ - 'success_url' => route('marketing.success', [ - 'locale' => app()->getLocale(), - 'packageId' => $package->id, - ]), - 'return_url' => route('packages', [ - 'locale' => app()->getLocale(), - 'highlight' => $package->slug, - ]), - 'metadata' => [ - 'checkout_session_id' => $session->id, - 'coupon_code' => $couponCode, - 'legal_version' => $session->legal_version, - 'accepted_terms' => (bool) $session->accepted_terms_at, - 'accepted_waiver' => $requiresWaiver && (bool) $session->digital_content_waiver_at, - ], - 'discount_code' => $couponCode, + $successUrl = route('marketing.success', [ + 'locale' => app()->getLocale(), + 'packageId' => $package->id, + ]); + $cancelUrl = route('packages', [ + 'locale' => app()->getLocale(), + 'highlight' => $package->slug, ]); + try { + $checkout = $this->paypalOrders->createOrder($session, $package, [ + 'return_url' => route('paypal.return', absolute: true), + 'cancel_url' => route('paypal.return', absolute: true), + 'locale' => app()->getLocale(), + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal checkout failed', [ + 'package_id' => $package->id, + 'tenant_id' => $tenant->id, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + throw ValidationException::withMessages([ + 'paypal' => __('marketing.packages.paypal_checkout_failed'), + ]); + } + + $orderId = $checkout['id'] ?? null; + if (! is_string($orderId) || $orderId === '') { + throw ValidationException::withMessages([ + 'paypal' => __('marketing.packages.paypal_checkout_failed'), + ]); + } + + $redirectUrl = $this->paypalOrders->resolveApproveUrl($checkout); + $session->forceFill([ - 'lemonsqueezy_checkout_id' => $checkout['id'] ?? $session->lemonsqueezy_checkout_id, + 'paypal_order_id' => $orderId, 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ - 'lemonsqueezy_checkout_id' => $checkout['id'] ?? null, - 'lemonsqueezy_checkout_url' => $checkout['checkout_url'] ?? null, - 'lemonsqueezy_expires_at' => $checkout['expires_at'] ?? null, + 'paypal_order_id' => $orderId, + 'paypal_status' => $checkout['status'] ?? null, + 'paypal_approve_url' => $redirectUrl, + 'paypal_success_url' => $successUrl, + 'paypal_cancel_url' => $cancelUrl, + 'paypal_created_at' => now()->toIso8601String(), ])), ])->save(); - $redirectUrl = $checkout['checkout_url'] ?? null; + $this->checkoutSessions->markRequiresCustomerAction($session, 'paypal_approval'); if (! $redirectUrl) { throw ValidationException::withMessages([ - 'lemonsqueezy' => __('marketing.packages.lemonsqueezy_checkout_failed'), + 'paypal' => __('marketing.packages.paypal_checkout_failed'), ]); } diff --git a/app/Http/Controllers/PayPalCheckoutController.php b/app/Http/Controllers/PayPalCheckoutController.php new file mode 100644 index 00000000..ea8ccb05 --- /dev/null +++ b/app/Http/Controllers/PayPalCheckoutController.php @@ -0,0 +1,223 @@ +validated(); + + $user = $request->user(); + $tenant = $user?->tenant; + + if (! $tenant) { + throw ValidationException::withMessages(['tenant' => 'Tenant context missing.']); + } + + $package = Package::findOrFail((int) $data['package_id']); + + $session = $this->sessions->createOrResume($user, $package, array_merge( + CheckoutRequestContext::fromRequest($request), + [ + 'tenant' => $tenant, + 'locale' => $data['locale'] ?? null, + ] + )); + + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); + + $now = now(); + $session->forceFill([ + 'accepted_terms_at' => $now, + 'accepted_privacy_at' => $now, + 'accepted_withdrawal_notice_at' => $now, + 'digital_content_waiver_at' => null, + 'legal_version' => $this->resolveLegalVersion(), + ])->save(); + + $couponCode = Str::upper(trim((string) ($data['coupon_code'] ?? ''))); + + if ($couponCode !== '') { + $preview = $this->coupons->preview($couponCode, $package, $tenant); + $this->sessions->applyCoupon($session, $preview['coupon'], $preview['pricing']); + } + + $successUrl = $data['return_url'] ?? null; + $cancelUrl = $data['cancel_url'] ?? $successUrl; + $paypalReturnUrl = route('paypal.return', absolute: true); + + try { + $order = $this->orders->createOrder($session, $package, [ + 'return_url' => $paypalReturnUrl, + 'cancel_url' => $paypalReturnUrl, + 'locale' => $data['locale'] ?? $session->locale, + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal order creation failed', [ + 'session_id' => $session->id, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + 'context' => $exception->context(), + ]); + + throw ValidationException::withMessages([ + 'paypal' => __('marketing.packages.paypal_checkout_failed', [], app()->getLocale()) + ?: 'Unable to create PayPal checkout.', + ]); + } + + $orderId = $order['id'] ?? null; + if (! is_string($orderId) || $orderId === '') { + throw ValidationException::withMessages([ + 'paypal' => 'PayPal order ID missing.', + ]); + } + + $approveUrl = $this->orders->resolveApproveUrl($order); + + $session->forceFill([ + 'paypal_order_id' => $orderId, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paypal_order_id' => $orderId, + 'paypal_status' => $order['status'] ?? null, + 'paypal_approve_url' => $approveUrl, + 'paypal_success_url' => $successUrl, + 'paypal_cancel_url' => $cancelUrl, + 'paypal_created_at' => now()->toIso8601String(), + ])), + ])->save(); + + $this->sessions->markRequiresCustomerAction($session, 'paypal_approval'); + + return response()->json([ + 'order_id' => $orderId, + 'approve_url' => $approveUrl, + 'status' => $order['status'] ?? null, + 'checkout_session_id' => $session->id, + ]); + } + + public function capture(PayPalCaptureRequest $request): JsonResponse + { + $data = $request->validated(); + + $session = CheckoutSession::findOrFail($data['checkout_session_id']); + $orderId = (string) $data['order_id']; + + if ($session->status === CheckoutSession::STATUS_COMPLETED) { + return response()->json([ + 'status' => $session->status, + 'completed_at' => optional($session->completed_at)->toIso8601String(), + 'checkout_session_id' => $session->id, + ]); + } + + if ($session->provider !== CheckoutSession::PROVIDER_PAYPAL) { + $this->sessions->selectProvider($session, CheckoutSession::PROVIDER_PAYPAL); + } + + try { + $capture = $this->orders->captureOrder($orderId, [ + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal capture failed', [ + 'session_id' => $session->id, + 'order_id' => $orderId, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + 'context' => $exception->context(), + ]); + + $this->sessions->markFailed($session, 'paypal_capture_failed'); + + return response()->json([ + 'status' => CheckoutSession::STATUS_FAILED, + 'checkout_session_id' => $session->id, + ], 422); + } + + $status = strtoupper((string) ($capture['status'] ?? '')); + $captureId = $this->orders->resolveCaptureId($capture); + $totals = $this->orders->resolveTotals($capture); + + $session->forceFill([ + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => $captureId, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paypal_order_id' => $orderId, + 'paypal_capture_id' => $captureId, + 'paypal_status' => $status ?: null, + 'paypal_totals' => $totals !== [] ? $totals : null, + 'paypal_captured_at' => now()->toIso8601String(), + ])), + ])->save(); + + if ($status === 'COMPLETED') { + $this->sessions->markProcessing($session, [ + 'paypal_status' => $status, + 'paypal_capture_id' => $captureId, + ]); + + $this->assignment->finalise($session, [ + 'source' => 'paypal_capture', + 'provider' => CheckoutSession::PROVIDER_PAYPAL, + 'provider_reference' => $captureId ?? $orderId, + 'payload' => $capture, + ]); + + $this->sessions->markCompleted($session, now()); + + return response()->json([ + 'status' => CheckoutSession::STATUS_COMPLETED, + 'completed_at' => optional($session->completed_at)->toIso8601String(), + 'checkout_session_id' => $session->id, + ]); + } + + if (in_array($status, ['PAYER_ACTION_REQUIRED', 'PENDING'], true)) { + $this->sessions->markRequiresCustomerAction($session, 'paypal_pending'); + + return response()->json([ + 'status' => CheckoutSession::STATUS_REQUIRES_CUSTOMER_ACTION, + 'checkout_session_id' => $session->id, + ], 202); + } + + $this->sessions->markFailed($session, 'paypal_'.$status); + + return response()->json([ + 'status' => CheckoutSession::STATUS_FAILED, + 'checkout_session_id' => $session->id, + ], 422); + } + + protected function resolveLegalVersion(): string + { + return config('app.legal_version', now()->toDateString()); + } +} diff --git a/app/Http/Controllers/PayPalReturnController.php b/app/Http/Controllers/PayPalReturnController.php new file mode 100644 index 00000000..07aa4612 --- /dev/null +++ b/app/Http/Controllers/PayPalReturnController.php @@ -0,0 +1,136 @@ +resolveOrderId($request); + $fallback = $this->resolveFallbackUrl(); + + if (! $orderId) { + return redirect()->to($fallback); + } + + $session = CheckoutSession::query() + ->where('paypal_order_id', $orderId) + ->first(); + + if (! $session) { + return redirect()->to($fallback); + } + + $successUrl = data_get($session->provider_metadata ?? [], 'paypal_success_url'); + $cancelUrl = data_get($session->provider_metadata ?? [], 'paypal_cancel_url'); + + if ($session->status === CheckoutSession::STATUS_COMPLETED) { + return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback)); + } + + try { + $capture = $this->orders->captureOrder($orderId, [ + 'request_id' => $session->id, + ]); + } catch (PayPalException $exception) { + Log::warning('PayPal return capture failed', [ + 'session_id' => $session->id, + 'order_id' => $orderId, + 'message' => $exception->getMessage(), + 'status' => $exception->status(), + ]); + + $this->sessions->markFailed($session, 'paypal_capture_failed'); + + return redirect()->to($this->resolveSafeRedirect($cancelUrl, $fallback)); + } + + $status = strtoupper((string) ($capture['status'] ?? '')); + $captureId = $this->orders->resolveCaptureId($capture); + $totals = $this->orders->resolveTotals($capture); + + $session->forceFill([ + 'paypal_capture_id' => $captureId, + 'provider_metadata' => array_merge($session->provider_metadata ?? [], array_filter([ + 'paypal_status' => $status ?: null, + 'paypal_capture_id' => $captureId, + 'paypal_totals' => $totals !== [] ? $totals : null, + 'paypal_captured_at' => now()->toIso8601String(), + ])), + ])->save(); + + if ($status === 'COMPLETED') { + $this->sessions->markProcessing($session, [ + 'paypal_status' => $status, + 'paypal_capture_id' => $captureId, + ]); + + $this->assignment->finalise($session, [ + 'source' => 'paypal_return', + 'provider' => CheckoutSession::PROVIDER_PAYPAL, + 'provider_reference' => $captureId ?? $orderId, + 'payload' => $capture, + ]); + + $this->sessions->markCompleted($session, now()); + + return redirect()->to($this->resolveSafeRedirect($successUrl, $fallback)); + } + + $this->sessions->markFailed($session, 'paypal_'.$status); + + return redirect()->to($this->resolveSafeRedirect($cancelUrl, $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; + } +} diff --git a/app/Http/Requests/PayPal/PayPalCaptureRequest.php b/app/Http/Requests/PayPal/PayPalCaptureRequest.php new file mode 100644 index 00000000..95b9e337 --- /dev/null +++ b/app/Http/Requests/PayPal/PayPalCaptureRequest.php @@ -0,0 +1,56 @@ +user(); + + if (! $user) { + return false; + } + + $sessionId = $this->input('checkout_session_id'); + + if (! is_string($sessionId) || $sessionId === '') { + return false; + } + + $session = CheckoutSession::find($sessionId); + + if (! $session) { + return false; + } + + return (int) $session->user_id === (int) $user->id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'checkout_session_id' => ['required', 'uuid', 'exists:checkout_sessions,id'], + 'order_id' => ['required', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'checkout_session_id.required' => 'Checkout-Session fehlt.', + 'order_id.required' => 'Order ID fehlt.', + ]; + } +} diff --git a/app/Http/Requests/PayPal/PayPalCheckoutRequest.php b/app/Http/Requests/PayPal/PayPalCheckoutRequest.php new file mode 100644 index 00000000..a754c985 --- /dev/null +++ b/app/Http/Requests/PayPal/PayPalCheckoutRequest.php @@ -0,0 +1,41 @@ +user(); + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'package_id' => ['required', 'exists:packages,id'], + 'return_url' => ['nullable', 'url'], + 'cancel_url' => ['nullable', 'url'], + 'coupon_code' => ['nullable', 'string', 'max:64'], + 'accepted_terms' => ['required', 'boolean', 'accepted'], + 'locale' => ['nullable', 'string', 'max:10'], + ]; + } + + public function messages(): array + { + return [ + 'package_id.exists' => 'Das ausgewählte Paket ist ungültig.', + 'accepted_terms.accepted' => 'Bitte akzeptiere die Nutzungsbedingungen.', + ]; + } +} diff --git a/app/Models/CheckoutSession.php b/app/Models/CheckoutSession.php index 1aa4634b..c14414e3 100644 --- a/app/Models/CheckoutSession.php +++ b/app/Models/CheckoutSession.php @@ -32,6 +32,8 @@ class CheckoutSession extends Model public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy'; + public const PROVIDER_PAYPAL = 'paypal'; + public const PROVIDER_FREE = 'free'; /** diff --git a/app/Services/Checkout/CheckoutAssignmentService.php b/app/Services/Checkout/CheckoutAssignmentService.php index 1f9be58f..1d115c62 100644 --- a/app/Services/Checkout/CheckoutAssignmentService.php +++ b/app/Services/Checkout/CheckoutAssignmentService.php @@ -69,7 +69,8 @@ class CheckoutAssignmentService ?? ($metadata['lemonsqueezy_order_id'] ?? $metadata['lemonsqueezy_checkout_id'] ? CheckoutSession::PROVIDER_LEMONSQUEEZY : null) ?? CheckoutSession::PROVIDER_FREE; - $totals = $this->resolveLemonSqueezyTotals($session, $options['payload'] ?? []); + $provider = $providerName ?: $session->provider; + $totals = $this->resolveProviderTotals($session, $options['payload'] ?? [], $provider); $currency = $totals['currency'] ?? $session->currency ?? $package->currency ?? 'EUR'; $price = array_key_exists('total', $totals) ? $totals['total'] : (float) $session->amount_total; @@ -88,7 +89,8 @@ class CheckoutAssignmentService 'payload' => $options['payload'] ?? null, 'checkout_session_id' => $session->id, 'consents' => $consents ?: null, - 'lemonsqueezy_totals' => $totals !== [] ? $totals : null, + 'lemonsqueezy_totals' => $provider === CheckoutSession::PROVIDER_LEMONSQUEEZY && $totals !== [] ? $totals : null, + 'paypal_totals' => $provider === CheckoutSession::PROVIDER_PAYPAL && $totals !== [] ? $totals : null, 'currency' => $currency, ], static fn ($value) => $value !== null && $value !== ''), ] @@ -219,6 +221,19 @@ class CheckoutAssignmentService ])->save(); } + /** + * @param array $payload + * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} + */ + protected function resolveProviderTotals(CheckoutSession $session, array $payload, ?string $provider): array + { + if ($provider === CheckoutSession::PROVIDER_PAYPAL) { + return $this->resolvePayPalTotals($session, $payload); + } + + return $this->resolveLemonSqueezyTotals($session, $payload); + } + /** * @param array $payload * @return array{currency?: string, subtotal?: float, discount?: float, tax?: float, total?: float} @@ -252,6 +267,34 @@ class CheckoutAssignmentService ], static fn ($value) => $value !== null); } + /** + * @param array $payload + * @return array{currency?: string, total?: float} + */ + protected function resolvePayPalTotals(CheckoutSession $session, array $payload): array + { + $metadataTotals = $session->provider_metadata['paypal_totals'] ?? null; + + if (is_array($metadataTotals) && $metadataTotals !== []) { + return $metadataTotals; + } + + $amount = Arr::get($payload, 'purchase_units.0.payments.captures.0.amount') + ?? Arr::get($payload, '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); + } + protected function convertMinorAmount(mixed $value): ?float { if ($value === null || $value === '') { diff --git a/app/Services/Checkout/CheckoutSessionService.php b/app/Services/Checkout/CheckoutSessionService.php index 5ce08293..3ce0f139 100644 --- a/app/Services/Checkout/CheckoutSessionService.php +++ b/app/Services/Checkout/CheckoutSessionService.php @@ -74,6 +74,8 @@ class CheckoutSessionService $session->status = CheckoutSession::STATUS_DRAFT; $session->lemonsqueezy_checkout_id = null; $session->lemonsqueezy_order_id = null; + $session->paypal_order_id = null; + $session->paypal_capture_id = null; $session->provider_metadata = []; $session->failure_reason = null; $session->coupon()->dissociate(); @@ -117,10 +119,20 @@ class CheckoutSessionService { $provider = strtolower($provider); - if (! in_array($provider, [ - CheckoutSession::PROVIDER_LEMONSQUEEZY, - CheckoutSession::PROVIDER_FREE, - ], true)) { + $configuredProviders = config('checkout.providers', []); + if (! is_array($configuredProviders) || $configuredProviders === []) { + $configuredProviders = [ + CheckoutSession::PROVIDER_LEMONSQUEEZY, + CheckoutSession::PROVIDER_PAYPAL, + CheckoutSession::PROVIDER_FREE, + ]; + } + + if (! in_array(CheckoutSession::PROVIDER_FREE, $configuredProviders, true)) { + $configuredProviders[] = CheckoutSession::PROVIDER_FREE; + } + + if (! in_array($provider, $configuredProviders, true)) { throw new RuntimeException("Unsupported checkout provider [{$provider}]"); } diff --git a/app/Services/PayPal/Exceptions/PayPalException.php b/app/Services/PayPal/Exceptions/PayPalException.php new file mode 100644 index 00000000..89a25c54 --- /dev/null +++ b/app/Services/PayPal/Exceptions/PayPalException.php @@ -0,0 +1,23 @@ +status; + } + + public function context(): array + { + return $this->context; + } +} diff --git a/app/Services/PayPal/PayPalClient.php b/app/Services/PayPal/PayPalClient.php new file mode 100644 index 00000000..08027d09 --- /dev/null +++ b/app/Services/PayPal/PayPalClient.php @@ -0,0 +1,145 @@ +send('GET', $endpoint, [ + 'query' => $query, + 'headers' => $headers, + ]); + } + + public function post(string $endpoint, array|object $payload = [], array $headers = []): array + { + return $this->send('POST', $endpoint, [ + 'json' => $payload, + 'headers' => $headers, + ]); + } + + protected function send(string $method, string $endpoint, array $options = []): array + { + $request = $this->preparedRequest(); + + try { + $response = $request->send(strtoupper($method), ltrim($endpoint, '/'), $options); + } catch (RequestException $exception) { + throw new PayPalException( + $exception->getMessage(), + $exception->response?->status(), + $exception->response?->json() ?? [] + ); + } + + if ($response->failed()) { + $body = $response->json() ?? []; + $message = Arr::get($body, 'message') + ?? Arr::get($body, 'details.0.description') + ?? sprintf('PayPal request failed with status %s', $response->status()); + + throw new PayPalException($message, $response->status(), $body); + } + + return $response->json() ?? []; + } + + protected function preparedRequest(): PendingRequest + { + $baseUrl = $this->baseUrl(); + $token = $this->accessToken(); + + return $this->http + ->baseUrl($baseUrl) + ->withHeaders([ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + 'User-Agent' => sprintf('FotospielApp/%s PayPalClient', app()->version()), + ]) + ->withToken($token) + ->acceptJson() + ->asJson(); + } + + protected function accessToken(): string + { + $cacheKey = 'paypal:access_token'; + $cached = Cache::get($cacheKey); + + if (is_string($cached) && $cached !== '') { + return $cached; + } + + $tokenResponse = $this->requestAccessToken(); + $accessToken = Arr::get($tokenResponse, 'access_token'); + + if (! is_string($accessToken) || $accessToken === '') { + throw new PayPalException('PayPal access token missing from response.', null, $tokenResponse); + } + + $expiresIn = (int) Arr::get($tokenResponse, 'expires_in', 0); + if ($expiresIn > 60) { + Cache::put($cacheKey, $accessToken, now()->addSeconds($expiresIn - 60)); + } + + return $accessToken; + } + + protected function requestAccessToken(): array + { + $clientId = config('services.paypal.client_id'); + $secret = config('services.paypal.secret'); + + if (! $clientId || ! $secret) { + throw new PayPalException('PayPal client credentials are not configured.'); + } + + $response = $this->http + ->baseUrl($this->baseUrl()) + ->withBasicAuth($clientId, $secret) + ->asForm() + ->acceptJson() + ->post('/v1/oauth2/token', [ + 'grant_type' => 'client_credentials', + ]); + + if ($response->failed()) { + $body = $response->json() ?? []; + $message = Arr::get($body, 'error_description') + ?? Arr::get($body, 'message') + ?? sprintf('PayPal OAuth failed with status %s', $response->status()); + + throw new PayPalException($message, $response->status(), $body); + } + + return $response->json() ?? []; + } + + protected function baseUrl(): string + { + $sandbox = (bool) config('services.paypal.sandbox', true); + $baseUrl = $sandbox ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; + + $baseUrl = trim($baseUrl); + + if (! Str::startsWith($baseUrl, ['http://', 'https://'])) { + $baseUrl = 'https://'.ltrim($baseUrl, '/'); + } + + return rtrim($baseUrl, '/'); + } +} diff --git a/app/Services/PayPal/PayPalOrderService.php b/app/Services/PayPal/PayPalOrderService.php new file mode 100644 index 00000000..c3d6660c --- /dev/null +++ b/app/Services/PayPal/PayPalOrderService.php @@ -0,0 +1,161 @@ + + */ + public function createOrder(CheckoutSession $session, Package $package, array $options = []): array + { + $currency = strtoupper((string) ($session->currency ?: $package->currency ?: 'EUR')); + $total = $this->formatAmount((float) $session->amount_total); + $subtotal = $this->formatAmount((float) $session->amount_subtotal); + $discount = $this->formatAmount((float) $session->amount_discount); + + $amount = [ + 'currency_code' => $currency, + 'value' => $total, + ]; + + if ($subtotal !== null && $discount !== null) { + $amount['breakdown'] = [ + 'item_total' => [ + 'currency_code' => $currency, + 'value' => $subtotal, + ], + 'discount' => [ + 'currency_code' => $currency, + 'value' => $discount, + ], + ]; + } + + $purchaseUnit = [ + 'reference_id' => 'package-'.$package->id, + 'description' => Str::limit($package->name ?? 'Package', 127, ''), + 'custom_id' => $session->id, + 'amount' => $amount, + ]; + + $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'] ?? $session->locale), + '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'] ?? $session->id; + 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 + */ + public function captureOrder(string $orderId, array $options = []): array + { + $headers = []; + $requestId = $options['request_id'] ?? null; + if (is_string($requestId) && $requestId !== '') { + $headers['PayPal-Request-Id'] = $requestId; + } + + return $this->client->post(sprintf('/v2/checkout/orders/%s/capture', $orderId), [], $headers); + } + + public function resolveApproveUrl(array $payload): ?string + { + $links = Arr::get($payload, 'links', []); + if (! is_array($links)) { + return null; + } + + foreach ($links as $link) { + if (! is_array($link)) { + continue; + } + + if (($link['rel'] ?? null) === 'approve') { + return is_string($link['href'] ?? null) ? $link['href'] : null; + } + } + + return null; + } + + public function resolveCaptureId(array $payload): ?string + { + $captureId = Arr::get($payload, 'purchase_units.0.payments.captures.0.id'); + + return is_string($captureId) && $captureId !== '' ? $captureId : null; + } + + /** + * @return array{currency?: string, total?: float} + */ + public function resolveTotals(array $payload): array + { + $amount = Arr::get($payload, 'purchase_units.0.payments.captures.0.amount') + ?? Arr::get($payload, '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); + } + + protected function resolveLocale(?string $locale): ?string + { + if (! $locale) { + return null; + } + + $normalized = str_replace('_', '-', $locale); + + return match (strtolower($normalized)) { + 'de', 'de-de', 'de-at', 'de-ch' => 'de-DE', + 'en', 'en-gb' => 'en-GB', + default => 'en-US', + }; + } + + protected function formatAmount(float $amount): ?string + { + if (! is_finite($amount)) { + return null; + } + + return number_format($amount, 2, '.', ''); + } +} diff --git a/config/checkout.php b/config/checkout.php index 5d64464e..b24ce3e5 100644 --- a/config/checkout.php +++ b/config/checkout.php @@ -5,6 +5,9 @@ return [ 'feature_flag' => env('CHECKOUT_WIZARD_FLAG', 'checkout-wizard-2025'), 'session_ttl_minutes' => env('CHECKOUT_SESSION_TTL', 30), 'status_history_max' => env('CHECKOUT_STATUS_HISTORY_MAX', 25), + 'currency' => env('CHECKOUT_CURRENCY', 'EUR'), + 'default_provider' => env('CHECKOUT_DEFAULT_PROVIDER', 'paypal'), + 'providers' => array_filter(array_map('trim', explode(',', (string) env('CHECKOUT_PROVIDERS', 'paypal,free')))), 'fraud' => [ 'window_hours' => env('CHECKOUT_FRAUD_WINDOW_HOURS', 24), 'medium_failed' => env('CHECKOUT_FRAUD_MEDIUM_FAILED', 2), diff --git a/database/migrations/2026_02_04_114212_add_paypal_columns_to_checkout_sessions_table.php b/database/migrations/2026_02_04_114212_add_paypal_columns_to_checkout_sessions_table.php new file mode 100644 index 00000000..9c5661c9 --- /dev/null +++ b/database/migrations/2026_02_04_114212_add_paypal_columns_to_checkout_sessions_table.php @@ -0,0 +1,45 @@ +string('paypal_order_id')->nullable()->after('lemonsqueezy_order_id'); + } + + if (! Schema::hasColumn('checkout_sessions', 'paypal_capture_id')) { + $table->string('paypal_capture_id')->nullable()->after('paypal_order_id'); + } + + $table->unique('paypal_order_id'); + $table->unique('paypal_capture_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('checkout_sessions', function (Blueprint $table) { + if (Schema::hasColumn('checkout_sessions', 'paypal_order_id')) { + $table->dropUnique('checkout_sessions_paypal_order_id_unique'); + $table->dropColumn('paypal_order_id'); + } + + if (Schema::hasColumn('checkout_sessions', 'paypal_capture_id')) { + $table->dropUnique('checkout_sessions_paypal_capture_id_unique'); + $table->dropColumn('paypal_capture_id'); + } + }); + } +}; diff --git a/public/lang/de/marketing.json b/public/lang/de/marketing.json index c959f028..3b0dfaa7 100644 --- a/public/lang/de/marketing.json +++ b/public/lang/de/marketing.json @@ -146,7 +146,7 @@ "faq_q3": "Was passiert bei Ablauf?", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_q4": "Zahlungssicher?", - "faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.", + "faq_a4": "Ja, via PayPal - sicher und DSGVO-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", @@ -179,7 +179,7 @@ "billing_per_kontingent": "pro Kontingent", "more_features": "+{{count}} weitere Features", "feature_overview": "Feature-Überblick", - "order_hint": "Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.", + "order_hint": "Sofort startklar - sichere Zahlung ueber PayPal, keine versteckten Kosten.", "features_label": "Features", "feature_highlights": "Feature-Highlights", "detail_labels": { @@ -362,9 +362,9 @@ "euro": "€" }, "feature": "Feature", - "lemonsqueezy_not_configured": "Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.", - "lemonsqueezy_checkout_failed": "Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", - "gift_cta": "Paket verschenken" + "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", + "paypal_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut." }, "blog": { "title": "Fotospiel - Blog", @@ -530,7 +530,6 @@ "language_de": "Deutsch", "language_en": "English", "language_changed": "{{language}} ausgewählt", - "language": "Sprache", "open_menu": "Menü öffnen", "close_menu": "Menü schließen", "cta_demo": "Jetzt ausprobieren", @@ -685,24 +684,24 @@ "free_package_desc": "Dieses Paket ist kostenlos. Wir aktivieren es direkt nach der Bestätigung.", "activate_package": "Paket aktivieren", "loading_payment": "Zahlungsdaten werden geladen...", - "secure_payment_desc": "Sichere Zahlung über Lemon Squeezy.", - "lemonsqueezy_intro": "Starte den Lemon Squeezy-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", - "guided_title": "Sichere Zahlung mit Lemon Squeezy – unserem geprüften Partner", - "guided_body": "Wir führen dich Schritt für Schritt durch den Bezahlprozess. Lemon Squeezy wickelt den Kauf als Merchant of Record ab und sorgt dafür, dass Steuern und Rechnungen automatisch korrekt erstellt werden.", - "lemonsqueezy_partner": "Powered by Lemon Squeezy", + "secure_payment_desc": "Sichere Zahlung ueber PayPal.", + "lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", + "guided_title": "Sichere Zahlung mit PayPal", + "guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.", + "lemonsqueezy_partner": "Powered by PayPal", "trust_secure": "Verschlüsselte Zahlung", "trust_tax": "Automatische Steuerberechnung", "trust_support": "Support in Minuten", - "guided_cta_hint": "Lemon Squeezy wickelt deine Zahlung als Merchant of Record ab", + "guided_cta_hint": "Sicher abgewickelt ueber PayPal", "toast_success": "Zahlung erfolgreich – wir bereiten alles vor.", - "lemonsqueezy_preparing": "Lemon Squeezy-Checkout wird vorbereitet…", - "lemonsqueezy_overlay_ready": "Der Lemon Squeezy-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", - "lemonsqueezy_ready": "Lemon Squeezy-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", - "lemonsqueezy_error": "Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", - "lemonsqueezy_not_ready": "Der Lemon Squeezy-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.", - "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.", - "lemonsqueezy_disclaimer": "Lemon Squeezy wickelt Zahlungen als Merchant of Record ab. Steuern werden automatisch anhand deiner Rechnungsdaten berechnet.", - "pay_with_lemonsqueezy": "Weiter mit Lemon Squeezy", + "lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...", + "lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", + "lemonsqueezy_ready": "PayPal-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", + "lemonsqueezy_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "lemonsqueezy_not_ready": "Der PayPal-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.", + "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.", + "pay_with_lemonsqueezy": "Weiter mit PayPal", "continue_after_payment": "Ich habe die Zahlung abgeschlossen", "no_package_title": "Kein Paket ausgewählt", "no_package_description": "Bitte wähle ein Paket, um zum Checkout zu gelangen.", @@ -736,7 +735,14 @@ "processing_manual_hint": "Falls es zu lange dauert, pruefe den Status erneut oder lade die Seite neu.", "processing_retry": "Status erneut pruefen", "processing_refresh": "Seite neu laden", - "processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab..." + "processing_confirmation": "Zahlung eingegangen. Wir schliessen deine Bestellung ab...", + "paypal_partner": "Powered by PayPal", + "paypal_preparing": "PayPal-Checkout wird vorbereitet...", + "paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.", + "paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.", + "paypal_cancelled": "PayPal-Checkout wurde abgebrochen.", + "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung." }, "confirmation_step": { "title": "Bestätigung", diff --git a/public/lang/en/marketing.json b/public/lang/en/marketing.json index 3fe1dbb2..50813f09 100644 --- a/public/lang/en/marketing.json +++ b/public/lang/en/marketing.json @@ -133,7 +133,7 @@ "faq_q3": "What happens when it expires?", "faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.", "faq_q4": "Payment secure?", - "faq_a4": "Yes, via Lemon Squeezy – secure and GDPR compliant.", + "faq_a4": "Yes, via PayPal - secure and GDPR-compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", @@ -167,7 +167,7 @@ "billing_per_bundle": "per bundle", "more_features": "+{{count}} more features", "feature_overview": "Feature overview", - "order_hint": "Launch instantly – secure Lemon Squeezy checkout, no hidden fees.", + "order_hint": "Ready to launch instantly - secure PayPal checkout, no hidden fees.", "features_label": "Features", "feature_highlights": "Feature Highlights", "detail_labels": { @@ -353,9 +353,9 @@ "currency": { "euro": "€" }, - "lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.", - "lemonsqueezy_checkout_failed": "We could not start the Lemon Squeezy checkout. Please try again later.", - "gift_cta": "Gift a package" + "lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.", + "lemonsqueezy_checkout_failed": "We could not start the PayPal checkout. Please try again later.", + "paypal_checkout_failed": "We could not start the PayPal checkout. Please try again later." }, "blog": { "title": "Fotospiel - Blog", @@ -521,7 +521,6 @@ "language_de": "Deutsch", "language_en": "English", "language_changed": "{{language}} selected", - "language": "Language", "open_menu": "Open menu", "close_menu": "Close menu", "cta_demo": "Try it now", @@ -683,24 +682,24 @@ "free_package_desc": "This package is free. We activate it directly after confirmation.", "activate_package": "Activate Package", "loading_payment": "Payment data is loading...", - "secure_payment_desc": "Secure payment with Lemon Squeezy.", - "lemonsqueezy_intro": "Launch the Lemon Squeezy checkout right here in the wizard—no page changes required.", - "guided_title": "Secure checkout, powered by Lemon Squeezy", - "guided_body": "We walk you through every step. Lemon Squeezy acts as merchant of record, handles taxes automatically, and delivers compliant invoices instantly.", - "lemonsqueezy_partner": "Powered by Lemon Squeezy", + "secure_payment_desc": "Secure payment with PayPal.", + "lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.", + "guided_title": "Secure checkout with PayPal", + "guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.", + "lemonsqueezy_partner": "Powered by PayPal", "trust_secure": "Encrypted payment", "trust_tax": "Automatic tax handling", "trust_support": "Live support within minutes", - "guided_cta_hint": "Securely processed by Lemon Squeezy as Merchant of Record", + "guided_cta_hint": "Securely processed via PayPal", "toast_success": "Payment received – setting everything up for you.", - "lemonsqueezy_preparing": "Preparing Lemon Squeezy checkout…", - "lemonsqueezy_overlay_ready": "Lemon Squeezy checkout is running in a secure overlay. Complete the payment there and then continue here.", - "lemonsqueezy_ready": "Lemon Squeezy checkout opened in a new tab. Complete the payment and then continue here.", - "lemonsqueezy_error": "We could not start the Lemon Squeezy checkout. Please try again.", - "lemonsqueezy_not_ready": "Lemon Squeezy checkout is not ready yet. Please try again in a moment.", - "lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.", - "lemonsqueezy_disclaimer": "Lemon Squeezy processes payments as merchant of record. Taxes are calculated automatically based on your billing details.", - "pay_with_lemonsqueezy": "Continue with Lemon Squeezy", + "lemonsqueezy_preparing": "Preparing PayPal checkout...", + "lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.", + "lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.", + "lemonsqueezy_error": "We could not start the PayPal checkout. Please try again.", + "lemonsqueezy_not_ready": "PayPal checkout is not ready yet. Please try again in a moment.", + "lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.", + "lemonsqueezy_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.", + "pay_with_lemonsqueezy": "Continue with PayPal", "continue_after_payment": "I completed the payment", "no_package_title": "No package selected", "no_package_description": "Please choose a package to continue to checkout.", @@ -734,7 +733,14 @@ "processing_manual_hint": "If this takes too long, try again or refresh the page.", "processing_retry": "Retry status check", "processing_refresh": "Refresh page", - "processing_confirmation": "Payment received. Finalising your order..." + "processing_confirmation": "Payment received. Finalising your order...", + "paypal_partner": "Powered by PayPal", + "paypal_preparing": "Preparing PayPal checkout...", + "paypal_ready": "PayPal checkout is ready. Complete the payment to continue.", + "paypal_error": "We could not start the PayPal checkout. Please try again.", + "paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.", + "paypal_cancelled": "PayPal checkout was cancelled.", + "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase." }, "confirmation_step": { "title": "Confirmation", diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 8501fe64..9884a213 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -2760,11 +2760,11 @@ export async function getTenantLemonSqueezyTransactions(cursor?: string): Promis }; } -export async function createTenantLemonSqueezyCheckout( +export async function createTenantPayPalCheckout( packageId: number, urls?: { success_url?: string; return_url?: string } -): Promise<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }> { - const response = await authorizedFetch('/api/v1/tenant/packages/lemonsqueezy-checkout', { +): Promise<{ approve_url: string | null; order_id: string; status?: string; checkout_session_id?: string }> { + const response = await authorizedFetch('/api/v1/tenant/packages/paypal-checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -2773,7 +2773,7 @@ export async function createTenantLemonSqueezyCheckout( return_url: urls?.return_url, }), }); - return await jsonOrThrow<{ checkout_url: string; id: string; expires_at?: string; checkout_session_id?: string }>( + return await jsonOrThrow<{ approve_url: string | null; order_id: string; status?: string; checkout_session_id?: string }>( response, 'Failed to create checkout' ); @@ -2854,7 +2854,7 @@ export async function completeTenantPackagePurchase(params: { const payload: Record = { package_id: packageId }; if (orderId) { - payload.lemonsqueezy_order_id = orderId; + payload.paypal_order_id = orderId; } const response = await authorizedFetch('/api/v1/tenant/packages/complete', { diff --git a/resources/js/admin/mobile/hooks/usePackageCheckout.ts b/resources/js/admin/mobile/hooks/usePackageCheckout.ts index 1a78b7da..4ec4d3eb 100644 --- a/resources/js/admin/mobile/hooks/usePackageCheckout.ts +++ b/resources/js/admin/mobile/hooks/usePackageCheckout.ts @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import toast from 'react-hot-toast'; -import { createTenantLemonSqueezyCheckout } from '../../api'; +import { createTenantPayPalCheckout } from '../../api'; import { adminPath } from '../../constants'; import { getApiErrorMessage } from '../../lib/apiError'; import { storePendingCheckout } from '../lib/billingCheckout'; @@ -33,7 +33,7 @@ export function usePackageCheckout(): { cancelUrl.searchParams.set('checkout', 'cancel'); cancelUrl.searchParams.set('package_id', String(packageId)); - const { checkout_url, checkout_session_id } = await createTenantLemonSqueezyCheckout(packageId, { + const { approve_url, order_id, checkout_session_id } = await createTenantPayPalCheckout(packageId, { success_url: successUrl.toString(), return_url: cancelUrl.toString(), }); @@ -43,10 +43,15 @@ export function usePackageCheckout(): { packageId, checkoutSessionId: checkout_session_id, startedAt: Date.now(), + orderId: order_id, }); } - window.location.href = checkout_url; + if (!approve_url) { + throw new Error('PayPal checkout URL missing.'); + } + + window.location.href = approve_url; } catch (err) { toast.error(getApiErrorMessage(err, t('shop.errors.checkout', 'Checkout failed'))); setBusy(false); diff --git a/resources/js/admin/mobile/lib/billingCheckout.ts b/resources/js/admin/mobile/lib/billingCheckout.ts index e8d483d4..4273dc1c 100644 --- a/resources/js/admin/mobile/lib/billingCheckout.ts +++ b/resources/js/admin/mobile/lib/billingCheckout.ts @@ -1,6 +1,7 @@ export type PendingCheckout = { packageId: number | null; checkoutSessionId?: string | null; + orderId?: string | null; startedAt: number; }; @@ -36,12 +37,14 @@ export function loadPendingCheckout( ? parsed.packageId : null; const checkoutSessionId = typeof parsed.checkoutSessionId === 'string' ? parsed.checkoutSessionId : null; + const orderId = typeof parsed.orderId === 'string' ? parsed.orderId : null; if (now - parsed.startedAt > ttl) { return null; } return { packageId, checkoutSessionId, + orderId, startedAt: parsed.startedAt, }; } catch { diff --git a/resources/js/pages/marketing/CheckoutWizardPage.tsx b/resources/js/pages/marketing/CheckoutWizardPage.tsx index 93be8679..1dfd97c2 100644 --- a/resources/js/pages/marketing/CheckoutWizardPage.tsx +++ b/resources/js/pages/marketing/CheckoutWizardPage.tsx @@ -21,9 +21,11 @@ interface CheckoutWizardPageProps { error?: string | null; profile?: OAuthProfilePrefill | null; }; - lemonsqueezy?: { - store_id?: string | null; - test_mode?: boolean | null; + paypal?: { + client_id?: string | null; + currency?: string | null; + intent?: string | null; + locale?: string | null; }; } @@ -33,7 +35,7 @@ const CheckoutWizardPage: React.FC = ({ privacyHtml, googleAuth, facebookAuth, - lemonsqueezy, + paypal, }) => { const page = usePage<{ auth?: { user?: { id: number; email: string; name?: string; pending_purchase?: boolean } | null }, flash?: { verification?: { status: string; title?: string; message?: string } } }>(); const currentUser = page.props.auth?.user ?? null; @@ -93,7 +95,7 @@ const CheckoutWizardPage: React.FC = ({ initialAuthUser={currentUser ? { id: currentUser.id, email: currentUser.email ?? '', name: currentUser.name ?? undefined, pending_purchase: Boolean(currentUser.pending_purchase) } : null} googleProfile={googleProfile} facebookProfile={facebookProfile} - lemonsqueezy={lemonsqueezy ?? null} + paypal={paypal ?? null} /> diff --git a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx index 1d54db40..90f28809 100644 --- a/resources/js/pages/marketing/checkout/CheckoutWizard.tsx +++ b/resources/js/pages/marketing/checkout/CheckoutWizard.tsx @@ -27,9 +27,11 @@ interface CheckoutWizardProps { initialStep?: CheckoutStepId; googleProfile?: OAuthProfilePrefill | null; facebookProfile?: OAuthProfilePrefill | null; - lemonsqueezy?: { - store_id?: string | null; - test_mode?: boolean | null; + paypal?: { + client_id?: string | null; + currency?: string | null; + intent?: string | null; + locale?: string | null; } | null; } @@ -290,7 +292,7 @@ export const CheckoutWizard: React.FC = ({ initialStep, googleProfile, facebookProfile, - lemonsqueezy, + paypal, }) => { const [storedGoogleProfile, setStoredGoogleProfile] = useState(() => { if (typeof window === 'undefined') { @@ -382,7 +384,7 @@ export const CheckoutWizard: React.FC = ({ initialStep={initialStep} initialAuthUser={initialAuthUser ?? undefined} initialIsAuthenticated={Boolean(initialAuthUser)} - lemonsqueezy={lemonsqueezy ?? null} + paypal={paypal ?? null} > ({ const basePackage = { id: 1, price: 49, - lemonsqueezy_variant_id: 'pri_test_123', }; const StepIndicator = () => { @@ -31,24 +30,35 @@ const AuthSeeder = () => { }; describe('PaymentStep', () => { + let paypalOptions: Record | null = null; + beforeEach(() => { localStorage.clear(); - window.LemonSqueezy = { - Setup: vi.fn(), - Url: { Open: vi.fn() }, + paypalOptions = null; + window.paypal = { + Buttons: (options: Record) => { + paypalOptions = options; + return { + render: async () => {}, + }; + }, }; vi.stubGlobal('fetch', vi.fn()); }); afterEach(() => { cleanup(); - delete window.LemonSqueezy; + delete window.paypal; vi.unstubAllGlobals(); }); it('renders the payment experience without crashing', async () => { render( - + , ); @@ -57,7 +67,7 @@ describe('PaymentStep', () => { expect(screen.queryByText('checkout.legal.checkbox_digital_content_label')).not.toBeInTheDocument(); }); - it('skips Lemon Squeezy payment when backend simulates checkout', async () => { + it('completes PayPal checkout when capture succeeds', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockImplementation(async (input) => { if (typeof input === 'string' && input.includes('/sanctum/csrf-cookie')) { @@ -66,22 +76,32 @@ describe('PaymentStep', () => { status: 204, redirected: false, url: '', + json: async () => ({}), text: async () => '', } as Response; } - if (typeof input === 'string' && input.includes('/lemonsqueezy/create-checkout')) { + if (typeof input === 'string' && input.includes('/paypal/create-order')) { + const payload = { checkout_session_id: 'session_123', order_id: 'order_123' }; return { ok: true, status: 200, redirected: false, url: '', - text: async () => JSON.stringify({ - simulated: true, - checkout_session_id: 'session_123', - order_id: 'order_123', - id: 'chk_123', - }), + json: async () => payload, + text: async () => JSON.stringify(payload), + } as Response; + } + + if (typeof input === 'string' && input.includes('/paypal/capture-order')) { + const payload = { status: 'completed' }; + return { + ok: true, + status: 200, + redirected: false, + url: '', + json: async () => payload, + text: async () => JSON.stringify(payload), } as Response; } @@ -90,6 +110,7 @@ describe('PaymentStep', () => { status: 200, redirected: false, url: '', + json: async () => ({}), text: async () => '', } as Response; }); @@ -99,6 +120,7 @@ describe('PaymentStep', () => { initialPackage={basePackage} packageOptions={[basePackage]} initialStep="payment" + paypal={{ client_id: 'client', currency: 'EUR', intent: 'capture' }} > @@ -106,14 +128,24 @@ describe('PaymentStep', () => { , ); - fireEvent.click(await screen.findByRole('checkbox')); - const ctas = await screen.findAllByText('checkout.payment_step.pay_with_lemonsqueezy'); - fireEvent.click(ctas[0]); + const checkbox = await screen.findByRole('checkbox'); + fireEvent.click(checkbox); + + await waitFor(() => { + expect(checkbox).toBeChecked(); + }); + + await waitFor(() => { + expect(paypalOptions).not.toBeNull(); + }); + + await act(async () => { + await paypalOptions?.createOrder?.(); + await paypalOptions?.onApprove?.({ orderID: 'order_123' }); + }); await waitFor(() => { expect(screen.getByTestId('current-step')).toHaveTextContent('confirmation'); }); - - expect(window.LemonSqueezy?.Url.Open).not.toHaveBeenCalled(); }); }); diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index a1c0f0fd..8967c9f8 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -19,27 +19,19 @@ import { useAnalytics } from '@/hooks/useAnalytics'; type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error'; -type LemonSqueezyEvent = { - event?: string; - data?: Record; -}; - declare global { interface Window { - createLemonSqueezy?: () => void; - LemonSqueezy?: { - Setup: (options: { eventHandler?: (event: LemonSqueezyEvent) => void }) => void; - Refresh?: () => void; - Url: { - Open: (url: string) => void; - Close?: () => void; + paypal?: { + Buttons: (options: Record) => { + render: (selector: HTMLElement | string) => Promise; + close?: () => void; }; }; } } -const LEMON_SCRIPT_URL = 'https://app.lemonsqueezy.com/js/lemon.js'; const PRIMARY_CTA_STYLES = 'min-w-[200px] disabled:bg-muted disabled:text-muted-foreground disabled:hover:bg-muted disabled:hover:text-muted-foreground'; +const PAYPAL_SDK_BASE = 'https://www.paypal.com/sdk/js'; const getCookieValue = (name: string): string | null => { if (typeof document === 'undefined') { @@ -116,54 +108,59 @@ export function resolveCheckoutLocale(rawLocale?: string | null): string { return short || 'en'; } -let lemonLoaderPromise: Promise | null = null; +type PayPalSdkOptions = { + clientId: string; + currency: string; + intent: string; + locale?: string | null; +}; -async function loadLemonSqueezy(): Promise { +let paypalLoaderPromise: Promise | null = null; +let paypalLoaderKey: string | null = null; + +async function loadPayPalSdk(options: PayPalSdkOptions): Promise { if (typeof window === 'undefined') { return null; } - if (window.LemonSqueezy) { - return window.LemonSqueezy; + if (window.paypal) { + return window.paypal; } - if (!lemonLoaderPromise) { - lemonLoaderPromise = new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = LEMON_SCRIPT_URL; - script.defer = true; - script.onload = () => { - window.createLemonSqueezy?.(); - resolve(window.LemonSqueezy ?? null); - }; - script.onerror = (error) => reject(error); - document.head.appendChild(script); - }).catch((error) => { - console.error('Failed to load Lemon.js', error); - lemonLoaderPromise = null; - return null; - }); + const params = new URLSearchParams({ + 'client-id': options.clientId, + currency: options.currency, + intent: options.intent, + components: 'buttons', + }); + + if (options.locale) { + params.set('locale', options.locale); } - return lemonLoaderPromise; + const src = `${PAYPAL_SDK_BASE}?${params.toString()}`; + + if (paypalLoaderPromise && paypalLoaderKey === src) { + return paypalLoaderPromise; + } + + paypalLoaderKey = src; + paypalLoaderPromise = new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.onload = () => resolve(window.paypal ?? null); + script.onerror = (error) => reject(error); + document.head.appendChild(script); + }).catch((error) => { + console.error('Failed to load PayPal SDK', error); + paypalLoaderPromise = null; + return null; + }); + + return paypalLoaderPromise; } -const LemonSqueezyCta: React.FC<{ onCheckout: () => Promise; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => { - const { t } = useTranslation('marketing'); - - return ( - - ); -}; - export const PaymentStep: React.FC = () => { const { t, i18n } = useTranslation('marketing'); const { trackEvent } = useAnalytics(); @@ -176,10 +173,10 @@ export const PaymentStep: React.FC = () => { setPaymentCompleted, checkoutSessionId, setCheckoutSessionId, + paypalConfig, } = useCheckoutWizard(); const [status, setStatus] = useState('idle'); const [message, setMessage] = useState(''); - const [inlineActive, setInlineActive] = useState(false); const [acceptedTerms, setAcceptedTerms] = useState(false); const [consentError, setConsentError] = useState(null); const [couponCode, setCouponCode] = useState(() => { @@ -199,10 +196,6 @@ export const PaymentStep: React.FC = () => { const [couponError, setCouponError] = useState(null); const [couponNotice, setCouponNotice] = useState(null); const [couponLoading, setCouponLoading] = useState(false); - const lemonRef = useRef(null); - const eventHandlerRef = useRef<(event: LemonSqueezyEvent) => void>(); - const lastCheckoutIdRef = useRef(null); - const hasAutoAppliedCoupon = useRef(false); const [showWithdrawalModal, setShowWithdrawalModal] = useState(false); const [withdrawalHtml, setWithdrawalHtml] = useState(null); const [withdrawalTitle, setWithdrawalTitle] = useState(null); @@ -212,10 +205,25 @@ export const PaymentStep: React.FC = () => { const [voucherExpiry, setVoucherExpiry] = useState(null); const [isGiftVoucher, setIsGiftVoucher] = useState(false); const [freeActivationBusy, setFreeActivationBusy] = useState(false); - const [pendingConfirmation, setPendingConfirmation] = useState<{ - orderId: string | null; - checkoutId: string | null; - } | null>(null); + + const paypalContainerRef = useRef(null); + const paypalButtonsRef = useRef<{ close?: () => void } | null>(null); + const paypalActionsRef = useRef<{ enable: () => void; disable: () => void } | null>(null); + const checkoutSessionRef = useRef(checkoutSessionId); + const acceptedTermsRef = useRef(acceptedTerms); + const couponCodeRef = useRef(null); + + useEffect(() => { + checkoutSessionRef.current = checkoutSessionId; + }, [checkoutSessionId]); + + useEffect(() => { + acceptedTermsRef.current = acceptedTerms; + }, [acceptedTerms]); + + useEffect(() => { + couponCodeRef.current = couponPreview?.coupon.code ?? null; + }, [couponPreview]); const checkoutLocale = useMemo(() => { const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); @@ -224,31 +232,6 @@ export const PaymentStep: React.FC = () => { const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); - const confirmCheckoutSession = useCallback(async (payload: { orderId: string | null; checkoutId: string | null }) => { - if (!checkoutSessionId) { - return; - } - - if (!payload.orderId && !payload.checkoutId) { - return; - } - - try { - await refreshCheckoutCsrfToken(); - await fetch(`/checkout/session/${checkoutSessionId}/confirm`, { - method: 'POST', - headers: buildCheckoutHeaders(), - credentials: 'same-origin', - body: JSON.stringify({ - order_id: payload.orderId, - checkout_id: payload.checkoutId, - }), - }); - } catch (error) { - console.warn('Failed to confirm Lemon Squeezy session', error); - } - }, [checkoutSessionId]); - const applyCoupon = useCallback(async (code: string) => { if (!selectedPackage) { return; @@ -310,12 +293,7 @@ export const PaymentStep: React.FC = () => { }, [RateLimitHelper, selectedPackage, t, trackEvent]); useEffect(() => { - if (hasAutoAppliedCoupon.current) { - return; - } - if (couponCode && selectedPackage) { - hasAutoAppliedCoupon.current = true; applyCoupon(couponCode); } }, [applyCoupon, couponCode, selectedPackage]); @@ -324,7 +302,6 @@ export const PaymentStep: React.FC = () => { setCouponPreview(null); setCouponNotice(null); setCouponError(null); - hasAutoAppliedCoupon.current = false; }, [selectedPackage?.id]); useEffect(() => { @@ -383,7 +360,7 @@ export const PaymentStep: React.FC = () => { const payload = await response.json().catch(() => ({})); if (!response.ok) { - const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.lemonsqueezy_error'); + const errorMessage = payload?.errors?.accepted_waiver?.[0] || payload?.message || t('checkout.payment_step.paypal_error'); setConsentError(errorMessage); toast.error(errorMessage); return; @@ -394,7 +371,7 @@ export const PaymentStep: React.FC = () => { nextStep(); } catch (error) { console.error('Failed to activate free package', error); - const fallbackMessage = t('checkout.payment_step.lemonsqueezy_error'); + const fallbackMessage = t('checkout.payment_step.paypal_error'); setConsentError(fallbackMessage); toast.error(fallbackMessage); } finally { @@ -402,216 +379,192 @@ export const PaymentStep: React.FC = () => { } }; - const startLemonSqueezyCheckout = async () => { - if (!selectedPackage) { - return; + const handlePayPalCapture = useCallback(async (orderId: string) => { + const sessionId = checkoutSessionRef.current; + if (!sessionId) { + throw new Error('Missing checkout session'); } - if (!isAuthenticated || !authUser) { - const message = t('checkout.payment_step.auth_required'); - setStatus('error'); - setMessage(message); - toast.error(message); - goToStep('auth'); - return; + await refreshCheckoutCsrfToken(); + const response = await fetch('/paypal/capture-order', { + method: 'POST', + headers: buildCheckoutHeaders(), + credentials: 'same-origin', + body: JSON.stringify({ + checkout_session_id: sessionId, + order_id: orderId, + }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + throw new Error(payload?.message || t('checkout.payment_step.paypal_error')); } - if (!acceptedTerms) { - setConsentError(t('checkout.legal.checkbox_terms_error')); - return; - } - - if (!selectedPackage.lemonsqueezy_variant_id) { - setStatus('error'); - setMessage(t('checkout.payment_step.lemonsqueezy_not_configured')); - return; - } - - setPaymentCompleted(false); - setStatus('processing'); - setMessage(t('checkout.payment_step.lemonsqueezy_preparing')); - setInlineActive(false); - setCheckoutSessionId(null); - - try { - await refreshCheckoutCsrfToken(); - const response = await fetch('/lemonsqueezy/create-checkout', { - method: 'POST', - headers: buildCheckoutHeaders(), - credentials: 'same-origin', - body: JSON.stringify({ - package_id: selectedPackage.id, - locale: checkoutLocale, - coupon_code: couponPreview?.coupon.code ?? undefined, - accepted_terms: acceptedTerms, - }), - }); - - const rawBody = await response.text(); - if ( - response.status === 401 || - response.status === 419 || - (response.redirected && response.url.includes('/login')) - ) { - const message = t('checkout.payment_step.auth_required'); - setStatus('error'); - setMessage(message); - toast.error(message); - goToStep('auth'); - return; - } - if (typeof window !== 'undefined') { - - console.info('[Checkout] Lemon Squeezy checkout response', { status: response.status, rawBody }); - } - - let data: { checkout_url?: string; message?: string; simulated?: boolean; order_id?: string; checkout_id?: string; id?: string; checkout_session_id?: string } | null = null; - try { - data = rawBody && rawBody.trim().startsWith('{') ? JSON.parse(rawBody) : null; - } catch (parseError) { - console.warn('Failed to parse Lemon Squeezy checkout payload as JSON', parseError); - data = null; - } - - const checkoutSession = data?.checkout_session_id ?? null; - if (checkoutSession && typeof checkoutSession === 'string') { - setCheckoutSessionId(checkoutSession); - } - - if (data?.simulated) { - const orderId = typeof data.order_id === 'string' ? data.order_id : null; - const checkoutId = typeof data.checkout_id === 'string' - ? data.checkout_id - : (typeof data.id === 'string' ? data.id : null); - - setStatus('processing'); - setMessage(t('checkout.payment_step.processing_confirmation')); - setInlineActive(false); - setPendingConfirmation({ orderId, checkoutId }); - setPaymentCompleted(true); - nextStep(); - return; - } - - let checkoutUrl: string | null = typeof data?.checkout_url === 'string' ? data.checkout_url : null; - - if (!checkoutUrl) { - const trimmed = rawBody.trim(); - if (/^https?:\/\//i.test(trimmed)) { - checkoutUrl = trimmed; - } else if (trimmed.startsWith('<')) { - const match = trimmed.match(/https?:\/\/["'a-zA-Z0-9._~:/?#@!$&'()*+,;=%-]+/); - if (match) { - checkoutUrl = match[0]; - } - } - } - - if (!response.ok || !checkoutUrl) { - const message = data?.message || rawBody || 'Unable to create Lemon Squeezy checkout.'; - throw new Error(message); - } - - if (data && typeof (data as { id?: string }).id === 'string') { - lastCheckoutIdRef.current = (data as { id?: string }).id ?? null; - } - - const lemon = await loadLemonSqueezy(); - - if (lemon?.Url?.Open) { - lemon.Url.Open(checkoutUrl); - setInlineActive(true); - setStatus('ready'); - setMessage(t('checkout.payment_step.lemonsqueezy_overlay_ready')); - return; - } - - window.open(checkoutUrl, '_blank', 'noopener'); - setInlineActive(false); - setStatus('ready'); - setMessage(t('checkout.payment_step.lemonsqueezy_ready')); - } catch (error) { - console.error('Failed to start Lemon Squeezy checkout', error); - setStatus('error'); - setMessage(t('checkout.payment_step.lemonsqueezy_error')); - setInlineActive(false); - setPaymentCompleted(false); - } - }; + return payload; + }, [t]); useEffect(() => { + if (!selectedPackage || isFree) { + return; + } + + const clientId = paypalConfig?.client_id ?? null; + if (!clientId) { + setStatus('error'); + setMessage(t('checkout.payment_step.paypal_not_configured')); + return; + } + let cancelled = false; - (async () => { - const lemon = await loadLemonSqueezy(); + const initButtons = async () => { + const paypal = await loadPayPalSdk({ + clientId, + currency: paypalConfig?.currency ?? 'EUR', + intent: paypalConfig?.intent ?? 'capture', + locale: paypalConfig?.locale ?? checkoutLocale, + }); - if (cancelled || !lemon) { + if (cancelled || !paypal || !paypalContainerRef.current) { return; } - try { - eventHandlerRef.current = (event) => { - if (!event?.event) { - return; - } - - if (typeof window !== 'undefined') { - - console.debug('[Checkout] Lemon Squeezy event', event); - } - - if (event.event === 'Checkout.Success') { - const data = event.data as { id?: string; identifier?: string; attributes?: { checkout_id?: string } } | undefined; - const orderId = typeof data?.id === 'string' ? data.id : (typeof data?.identifier === 'string' ? data.identifier : null); - const checkoutId = typeof data?.attributes?.checkout_id === 'string' - ? data?.attributes?.checkout_id - : lastCheckoutIdRef.current; - setStatus('processing'); - setMessage(t('checkout.payment_step.processing_confirmation')); - setInlineActive(false); - setPaymentCompleted(false); - setPendingConfirmation({ orderId, checkoutId }); - toast.success(t('checkout.payment_step.toast_success')); - setPaymentCompleted(true); - nextStep(); - } - }; - - lemon.Setup({ - eventHandler: (event) => eventHandlerRef.current?.(event), - }); - - lemonRef.current = lemon; - } catch (error) { - console.error('Failed to initialize Lemon.js', error); - setStatus('error'); - setMessage(t('checkout.payment_step.lemonsqueezy_error')); - setPaymentCompleted(false); + if (paypalButtonsRef.current?.close) { + paypalButtonsRef.current.close(); } - })(); + + paypalContainerRef.current.innerHTML = ''; + + paypalButtonsRef.current = paypal.Buttons({ + onInit: (_data: unknown, actions: { enable: () => void; disable: () => void }) => { + paypalActionsRef.current = actions; + if (!acceptedTermsRef.current) { + actions.disable(); + } + }, + createOrder: async () => { + if (!selectedPackage) { + throw new Error('Missing package'); + } + + if (!isAuthenticated || !authUser) { + const authMessage = t('checkout.payment_step.auth_required'); + setStatus('error'); + setMessage(authMessage); + toast.error(authMessage); + goToStep('auth'); + throw new Error(authMessage); + } + + if (!acceptedTermsRef.current) { + const consentMessage = t('checkout.legal.checkbox_terms_error'); + setConsentError(consentMessage); + throw new Error(consentMessage); + } + + setConsentError(null); + setStatus('processing'); + setMessage(t('checkout.payment_step.paypal_preparing')); + setPaymentCompleted(false); + setCheckoutSessionId(null); + + await refreshCheckoutCsrfToken(); + + const response = await fetch('/paypal/create-order', { + method: 'POST', + headers: buildCheckoutHeaders(), + credentials: 'same-origin', + body: JSON.stringify({ + package_id: selectedPackage.id, + locale: checkoutLocale, + coupon_code: couponCodeRef.current ?? undefined, + accepted_terms: acceptedTermsRef.current, + }), + }); + + const payload = await response.json().catch(() => ({})); + + if (!response.ok) { + const errorMessage = payload?.message || t('checkout.payment_step.paypal_error'); + setStatus('error'); + setMessage(errorMessage); + throw new Error(errorMessage); + } + + if (payload?.checkout_session_id) { + setCheckoutSessionId(payload.checkout_session_id); + checkoutSessionRef.current = payload.checkout_session_id; + } + + const orderId = payload?.order_id; + if (!orderId) { + throw new Error('PayPal order ID missing.'); + } + + setStatus('ready'); + setMessage(t('checkout.payment_step.paypal_ready')); + return orderId; + }, + onApprove: async (data: { orderID?: string }) => { + if (!data?.orderID) { + throw new Error('Missing PayPal order ID.'); + } + + setStatus('processing'); + setMessage(t('checkout.payment_step.processing_confirmation')); + + try { + const payload = await handlePayPalCapture(data.orderID); + if (payload?.status === 'completed') { + setPaymentCompleted(true); + toast.success(t('checkout.payment_step.toast_success')); + nextStep(); + return; + } + + setStatus('error'); + setMessage(t('checkout.payment_step.paypal_error')); + } catch (error) { + console.error('Failed to capture PayPal order', error); + setStatus('error'); + setMessage(t('checkout.payment_step.paypal_error')); + } + }, + onCancel: () => { + setStatus('idle'); + setMessage(t('checkout.payment_step.paypal_cancelled')); + }, + onError: (error: unknown) => { + console.error('PayPal button error', error); + setStatus('error'); + setMessage(t('checkout.payment_step.paypal_error')); + }, + }) as { render: (selector: HTMLElement | string) => Promise; close?: () => void }; + + await paypalButtonsRef.current.render(paypalContainerRef.current); + }; + + void initButtons(); return () => { cancelled = true; + if (paypalButtonsRef.current?.close) { + paypalButtonsRef.current.close(); + } }; - }, [nextStep, setPaymentCompleted, t]); + }, [authUser, checkoutLocale, goToStep, isAuthenticated, isFree, paypalConfig?.client_id, paypalConfig?.currency, paypalConfig?.intent, paypalConfig?.locale, selectedPackage, setCheckoutSessionId, setPaymentCompleted, t, handlePayPalCapture, nextStep]); useEffect(() => { - setPaymentCompleted(false); - setCheckoutSessionId(null); - setStatus('idle'); - setMessage(''); - setInlineActive(false); - setPendingConfirmation(null); - }, [selectedPackage?.id, setPaymentCompleted]); - - useEffect(() => { - if (!pendingConfirmation || !checkoutSessionId) { - return; + if (paypalActionsRef.current) { + if (acceptedTerms) { + paypalActionsRef.current.enable(); + } else { + paypalActionsRef.current.disable(); + } } - - void confirmCheckoutSession(pendingConfirmation); - setPendingConfirmation(null); - }, [checkoutSessionId, confirmCheckoutSession, pendingConfirmation]); + }, [acceptedTerms]); const handleCouponSubmit = useCallback((event: FormEvent) => { event.preventDefault(); @@ -665,7 +618,6 @@ export const PaymentStep: React.FC = () => { } }, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]); - if (!selectedPackage) { return ( @@ -738,7 +690,6 @@ export const PaymentStep: React.FC = () => { ); } - const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
@@ -746,10 +697,10 @@ export const PaymentStep: React.FC = () => {
); - const LemonSqueezyLogo = () => ( + const PayPalBadge = () => (
- Lemon Squeezy - {t('checkout.payment_step.lemonsqueezy_partner')} + PayPal + {t('checkout.payment_step.paypal_partner')}
); @@ -757,11 +708,10 @@ export const PaymentStep: React.FC = () => {
- {!inlineActive && ( -
+
- +

{t('checkout.payment_step.guided_title')}

{t('checkout.payment_step.guided_body')}

@@ -785,7 +735,7 @@ export const PaymentStep: React.FC = () => { setConsentError(null); } }} - className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#001835]" + className="border-white/60 data-[state=checked]:bg-white data-[state=checked]:text-[#003087]" />
- +

{t('checkout.payment_step.guided_cta_hint')}

@@ -831,7 +776,6 @@ export const PaymentStep: React.FC = () => {
- )}
@@ -907,20 +851,6 @@ export const PaymentStep: React.FC = () => { )}
- {!inlineActive && ( -
-

- {t('checkout.payment_step.lemonsqueezy_intro')} -

- -
- )} - {status !== 'idle' && ( @@ -939,8 +869,8 @@ export const PaymentStep: React.FC = () => { )} -

- {t('checkout.payment_step.lemonsqueezy_disclaimer')} +

+ {t('checkout.payment_step.paypal_disclaimer')}

diff --git a/resources/lang/de/marketing.json b/resources/lang/de/marketing.json index 722fae66..a19f2032 100644 --- a/resources/lang/de/marketing.json +++ b/resources/lang/de/marketing.json @@ -90,7 +90,7 @@ "faq_q3": "Was passiert bei Ablauf?", "faq_a3": "Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.", "faq_q4": "Zahlungssicher?", - "faq_a4": "Ja, via Lemon Squeezy – sicher und GDPR-konform.", + "faq_a4": "Ja, via PayPal - sicher und DSGVO-konform.", "final_cta": "Bereit für Ihr nächstes Event?", "contact_us": "Kontaktieren Sie uns", "feature_live_slideshow": "Live-Slideshow", @@ -259,7 +259,11 @@ "text": "Classic-Level ist ein guter Mittelweg für verschiedene Event-Typen." } ] - } + }, + "order_hint": "Sofort startklar - sichere Zahlung ueber PayPal, keine versteckten Kosten.", + "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.", + "paypal_checkout_failed": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut." }, "blog": { "title": "Fotospiel - Blog", @@ -459,5 +463,30 @@ "currency": { "euro": "€" } + }, + "checkout": { + "payment_step": { + "secure_payment_desc": "Sichere Zahlung ueber PayPal.", + "lemonsqueezy_intro": "Starte den PayPal-Checkout direkt hier im Wizard – ganz ohne Seitenwechsel.", + "guided_title": "Sichere Zahlung mit PayPal", + "guided_body": "Bezahle schnell und sicher mit PayPal. Dein Paket wird nach der Bestaetigung sofort freigeschaltet.", + "lemonsqueezy_partner": "Powered by PayPal", + "guided_cta_hint": "Sicher abgewickelt ueber PayPal", + "lemonsqueezy_preparing": "PayPal-Checkout wird vorbereitet...", + "lemonsqueezy_overlay_ready": "Der PayPal-Checkout läuft jetzt in einem Overlay. Schließe die Zahlung dort ab und kehre anschließend hierher zurück.", + "lemonsqueezy_ready": "PayPal-Checkout wurde in einem neuen Tab geöffnet. Schließe die Zahlung dort ab und kehre dann hierher zurück.", + "lemonsqueezy_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "lemonsqueezy_not_ready": "Der PayPal-Checkout ist noch nicht bereit. Bitte versuche es in einem Moment erneut.", + "lemonsqueezy_not_configured": "Dieses Paket ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.", + "lemonsqueezy_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung.", + "pay_with_lemonsqueezy": "Weiter mit PayPal", + "paypal_partner": "Powered by PayPal", + "paypal_preparing": "PayPal-Checkout wird vorbereitet...", + "paypal_ready": "PayPal-Checkout ist bereit. Schließe die Zahlung ab, um fortzufahren.", + "paypal_error": "Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es erneut.", + "paypal_not_configured": "PayPal ist noch nicht konfiguriert. Bitte kontaktiere den Support.", + "paypal_cancelled": "PayPal-Checkout wurde abgebrochen.", + "paypal_disclaimer": "Zahlungen werden sicher über PayPal verarbeitet. Du erhältst im Anschluss eine Bestätigung." + } } } diff --git a/resources/lang/de/marketing.php b/resources/lang/de/marketing.php index 8f2e8277..cf594b95 100644 --- a/resources/lang/de/marketing.php +++ b/resources/lang/de/marketing.php @@ -30,7 +30,7 @@ return [ 'faq_q3' => 'Was passiert bei Ablauf?', 'faq_a3' => 'Die Galerie bleibt lesbar, aber Uploads sind blockiert. Verlängern Sie einfach.', 'faq_q4' => 'Zahlungssicher?', - 'faq_a4' => 'Ja, via Lemon Squeezy – sicher und GDPR-konform.', + 'faq_a4' => 'Ja, via PayPal – sicher und GDPR-konform.', 'final_cta' => 'Bereit für Ihr nächstes Event?', 'contact_us' => 'Kontaktieren Sie uns', 'feature_live_slideshow' => 'Live-Slideshow', @@ -64,12 +64,13 @@ return [ 'gallery_days_label' => 'Galerie-Tage', 'recommended_usage_window' => 'Empfohlen innerhalb von 24 Monaten zu nutzen.', 'feature_overview' => 'Feature-Überblick', - 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über Lemon Squeezy.', + 'order_hint' => 'Sofort startklar – keine versteckten Kosten, sichere Zahlung über PayPal.', 'features_label' => 'Features', 'breakdown_label' => 'Leistungsübersicht', 'limits_label' => 'Limits & Kapazitäten', - 'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den Lemon Squeezy-Checkout konfiguriert. Bitte kontaktiere den Support.', - 'lemonsqueezy_checkout_failed' => 'Der Lemon Squeezy-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.', + 'lemonsqueezy_not_configured' => 'Dieses Package ist noch nicht für den PayPal-Checkout konfiguriert. Bitte kontaktiere den Support.', + 'lemonsqueezy_checkout_failed' => 'Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.', + 'paypal_checkout_failed' => 'Der PayPal-Checkout konnte nicht gestartet werden. Bitte versuche es später erneut.', 'package_not_found' => 'Dieses Package ist nicht verfügbar. Bitte wähle ein anderes aus.', ], 'nav' => [ diff --git a/resources/lang/en/marketing.json b/resources/lang/en/marketing.json index 37db1eb8..748b4f45 100644 --- a/resources/lang/en/marketing.json +++ b/resources/lang/en/marketing.json @@ -91,7 +91,7 @@ "faq_q3": "What happens when it expires?", "faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend it.", "faq_q4": "Payment secure?", - "faq_a4": "Yes, via Lemon Squeezy – secure and GDPR-compliant.", + "faq_a4": "Yes, via PayPal - secure and GDPR-compliant.", "final_cta": "Ready for your next event?", "contact_us": "Contact Us", "feature_live_slideshow": "Live Slideshow", @@ -260,7 +260,11 @@ "text": "Classic level is a solid middle ground for varied event types." } ] - } + }, + "order_hint": "Ready to launch instantly - secure PayPal checkout, no hidden fees.", + "lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.", + "lemonsqueezy_checkout_failed": "We could not start the PayPal checkout. Please try again later.", + "paypal_checkout_failed": "We could not start the PayPal checkout. Please try again later." }, "blog": { "title": "Fotospiel - Blog", @@ -455,5 +459,30 @@ }, "currency": { "euro": "€" + }, + "checkout": { + "payment_step": { + "secure_payment_desc": "Secure payment with PayPal.", + "lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.", + "guided_title": "Secure checkout with PayPal", + "guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.", + "lemonsqueezy_partner": "Powered by PayPal", + "guided_cta_hint": "Securely processed via PayPal", + "lemonsqueezy_preparing": "Preparing PayPal checkout...", + "lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.", + "lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.", + "lemonsqueezy_error": "We could not start the PayPal checkout. Please try again.", + "lemonsqueezy_not_ready": "PayPal checkout is not ready yet. Please try again in a moment.", + "lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.", + "lemonsqueezy_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.", + "pay_with_lemonsqueezy": "Continue with PayPal", + "paypal_partner": "Powered by PayPal", + "paypal_preparing": "Preparing PayPal checkout...", + "paypal_ready": "PayPal checkout is ready. Complete the payment to continue.", + "paypal_error": "We could not start the PayPal checkout. Please try again.", + "paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.", + "paypal_cancelled": "PayPal checkout was cancelled.", + "paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase." + } } } diff --git a/resources/lang/en/marketing.php b/resources/lang/en/marketing.php index 01383f27..2100b7e4 100644 --- a/resources/lang/en/marketing.php +++ b/resources/lang/en/marketing.php @@ -30,7 +30,7 @@ return [ 'faq_q3' => 'What happens when it expires?', 'faq_a3' => 'The gallery remains readable, but uploads are blocked. Simply extend it.', 'faq_q4' => 'Payment secure?', - 'faq_a4' => 'Yes, via Lemon Squeezy – secure and GDPR-compliant.', + 'faq_a4' => 'Yes, via PayPal - secure and GDPR-compliant.', 'final_cta' => 'Ready for your next event?', 'contact_us' => 'Contact Us', 'feature_live_slideshow' => 'Live Slideshow', @@ -64,12 +64,13 @@ return [ 'max_guests_label' => 'Max. guests', 'gallery_days_label' => 'Gallery days', 'feature_overview' => 'Feature overview', - 'order_hint' => 'Ready to launch instantly – secure Lemon Squeezy checkout, no hidden fees.', + 'order_hint' => 'Ready to launch instantly - secure PayPal checkout, no hidden fees.', 'features_label' => 'Features', 'breakdown_label' => 'At-a-glance', 'limits_label' => 'Limits & Capacity', - 'lemonsqueezy_not_configured' => 'This package is not ready for Lemon Squeezy checkout. Please contact support.', - 'lemonsqueezy_checkout_failed' => 'We could not start the Lemon Squeezy checkout. Please try again later.', + 'lemonsqueezy_not_configured' => 'This package is not ready for PayPal checkout. Please contact support.', + 'lemonsqueezy_checkout_failed' => 'We could not start the PayPal checkout. Please try again later.', + 'paypal_checkout_failed' => 'We could not start the PayPal checkout. Please try again later.', 'package_not_found' => 'This package is no longer available. Please choose another one.', ], 'nav' => [ diff --git a/routes/api.php b/routes/api.php index 55940d4b..aa8979d9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -445,7 +445,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); Route::post('/complete', [PackageController::class, 'completePurchase'])->name('packages.complete'); Route::post('/free', [PackageController::class, 'assignFree'])->name('packages.free'); - Route::post('/lemonsqueezy-checkout', [PackageController::class, 'createLemonSqueezyCheckout'])->name('packages.lemonsqueezy-checkout'); + Route::post('/paypal-checkout', [PackageController::class, 'createPayPalCheckout'])->name('packages.paypal-checkout'); Route::get('/checkout-session/{session}/status', [PackageController::class, 'checkoutSessionStatus']) ->name('packages.checkout-session.status'); }); diff --git a/routes/web.php b/routes/web.php index b8ba1f48..70439a55 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,8 @@ 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\PayPalCheckoutController; +use App\Http\Controllers\PayPalReturnController; use App\Http\Controllers\ProfileAccountController; use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileDataExportController; @@ -416,6 +418,8 @@ Route::middleware('auth')->group(function () { Route::post('/checkout/session/{session}/confirm', [CheckoutController::class, 'confirmSession']) ->whereUuid('session') ->name('checkout.session.confirm'); + Route::post('/paypal/create-order', [PayPalCheckoutController::class, 'create'])->name('paypal.order.create'); + Route::post('/paypal/capture-order', [PayPalCheckoutController::class, 'capture'])->name('paypal.order.capture'); Route::post('/lemonsqueezy/create-checkout', [LemonSqueezyCheckoutController::class, 'create'])->name('lemonsqueezy.checkout.create'); }); @@ -426,3 +430,6 @@ Route::post('/lemonsqueezy/webhook', [LemonSqueezyWebhookController::class, 'han ->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]) ->middleware('throttle:lemonsqueezy-webhook') ->name('lemonsqueezy.webhook'); + +Route::get('/paypal/return', PayPalReturnController::class) + ->name('paypal.return'); diff --git a/tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php b/tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php deleted file mode 100644 index 61d49e84..00000000 --- a/tests/Feature/Tenant/TenantLemonSqueezyCheckoutTest.php +++ /dev/null @@ -1,70 +0,0 @@ -create([ - 'lemonsqueezy_variant_id' => 'pri_test_123', - 'price' => 129, - ]); - - $checkoutService = Mockery::mock(LemonSqueezyCheckoutService::class); - $checkoutService->shouldReceive('createCheckout') - ->once() - ->withArgs(function ($tenant, $payloadPackage, array $payload) use ($package) { - return $tenant->is($this->tenant) - && $payloadPackage->is($package) - && array_key_exists('success_url', $payload) - && array_key_exists('return_url', $payload) - && array_key_exists('metadata', $payload) - && is_array($payload['metadata']) - && ! empty($payload['metadata']['checkout_session_id']); - }) - ->andReturn([ - 'checkout_url' => 'https://checkout.lemonsqueezy.test/checkout/123', - 'id' => 'chk_test_123', - ]); - $this->instance(LemonSqueezyCheckoutService::class, $checkoutService); - - $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/lemonsqueezy-checkout', [ - 'package_id' => $package->id, - ]); - - $response->assertOk() - ->assertJsonPath('checkout_url', 'https://checkout.lemonsqueezy.test/checkout/123') - ->assertJsonStructure(['checkout_session_id']); - } - - public function test_lemonsqueezy_checkout_requires_lemonsqueezy_variant_id(): void - { - $package = Package::factory()->create([ - 'lemonsqueezy_variant_id' => null, - 'price' => 129, - ]); - - $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/lemonsqueezy-checkout', [ - 'package_id' => $package->id, - ]); - - $response->assertStatus(422) - ->assertJsonStructure([ - 'errors' => [ - 'package_id' => [], - ], - ]); - } -} diff --git a/tests/Feature/Tenant/TenantPayPalCheckoutTest.php b/tests/Feature/Tenant/TenantPayPalCheckoutTest.php new file mode 100644 index 00000000..ff21e316 --- /dev/null +++ b/tests/Feature/Tenant/TenantPayPalCheckoutTest.php @@ -0,0 +1,70 @@ +create([ + 'price' => 129, + ]); + + $checkoutService = Mockery::mock(PayPalOrderService::class); + $checkoutService->shouldReceive('createOrder') + ->once() + ->withArgs(function ($session, $payloadPackage, array $payload) use ($package) { + return $session->tenant?->is($this->tenant) + && $payloadPackage->is($package) + && array_key_exists('return_url', $payload) + && array_key_exists('cancel_url', $payload) + && array_key_exists('request_id', $payload); + }) + ->andReturn([ + 'id' => 'order_test_123', + 'status' => 'CREATED', + 'links' => [ + [ + 'rel' => 'approve', + 'href' => 'https://paypal.test/checkout/123', + ], + ], + ]); + $checkoutService->shouldReceive('resolveApproveUrl') + ->once() + ->andReturn('https://paypal.test/checkout/123'); + $this->instance(PayPalOrderService::class, $checkoutService); + + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/paypal-checkout', [ + 'package_id' => $package->id, + ]); + + $response->assertOk() + ->assertJsonPath('approve_url', 'https://paypal.test/checkout/123') + ->assertJsonPath('order_id', 'order_test_123') + ->assertJsonStructure(['checkout_session_id']); + } + + public function test_paypal_checkout_requires_package_id(): void + { + $response = $this->authenticatedRequest('POST', '/api/v1/tenant/packages/paypal-checkout', []); + + $response->assertStatus(422) + ->assertJsonStructure([ + 'errors' => [ + 'package_id' => [], + ], + ]); + } +}