Add PayPal checkout provider

This commit is contained in:
Codex Agent
2026-02-04 12:18:14 +01:00
parent 56a39d0535
commit fc5dfb272c
33 changed files with 1586 additions and 571 deletions

View File

@@ -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,
]);
}
}

View File

@@ -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(),
],
]);
}

View File

@@ -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'),
]);
}

View File

@@ -0,0 +1,223 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\PayPal\PayPalCaptureRequest;
use App\Http\Requests\PayPal\PayPalCheckoutRequest;
use App\Models\CheckoutSession;
use App\Models\Package;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\Coupons\CouponService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use App\Support\CheckoutRequestContext;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class PayPalCheckoutController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
private readonly CouponService $coupons,
) {}
public function create(PayPalCheckoutRequest $request): JsonResponse
{
$data = $request->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());
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers;
use App\Models\CheckoutSession;
use App\Services\Checkout\CheckoutAssignmentService;
use App\Services\Checkout\CheckoutSessionService;
use App\Services\PayPal\Exceptions\PayPalException;
use App\Services\PayPal\PayPalOrderService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class PayPalReturnController extends Controller
{
public function __construct(
private readonly PayPalOrderService $orders,
private readonly CheckoutSessionService $sessions,
private readonly CheckoutAssignmentService $assignment,
) {}
public function __invoke(Request $request): RedirectResponse
{
$orderId = $this->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;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\PayPal;
use App\Models\CheckoutSession;
use Illuminate\Foundation\Http\FormRequest;
class PayPalCaptureRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$user = $this->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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\PayPal;
use Illuminate\Foundation\Http\FormRequest;
class PayPalCheckoutRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return (bool) $this->user();
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
];
}
}

View File

@@ -32,6 +32,8 @@ class CheckoutSession extends Model
public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
public const PROVIDER_PAYPAL = 'paypal';
public const PROVIDER_FREE = 'free';
/**

View File

@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $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 === '') {

View File

@@ -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}]");
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Services\PayPal\Exceptions;
use RuntimeException;
class PayPalException extends RuntimeException
{
public function __construct(string $message, private readonly ?int $status = null, private readonly array $context = [])
{
parent::__construct($message, $status ?? 0);
}
public function status(): ?int
{
return $this->status;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Services\PayPal;
use App\Services\PayPal\Exceptions\PayPalException;
use Illuminate\Http\Client\Factory as HttpFactory;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class PayPalClient
{
public function __construct(
private readonly HttpFactory $http,
) {}
public function get(string $endpoint, array $query = [], array $headers = []): array
{
return $this->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, '/');
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Services\PayPal;
use App\Models\CheckoutSession;
use App\Models\Package;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class PayPalOrderService
{
public function __construct(private readonly PayPalClient $client) {}
/**
* @param array{return_url?: string|null, cancel_url?: string|null, locale?: string|null, request_id?: string|null} $options
* @return array<string, mixed>
*/
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<string, mixed>
*/
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, '.', '');
}
}

View File

@@ -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),

View File

@@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('checkout_sessions', function (Blueprint $table) {
if (! Schema::hasColumn('checkout_sessions', 'paypal_order_id')) {
$table->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');
}
});
}
};

View File

@@ -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",

View File

@@ -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 wizardno 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",

View File

@@ -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<string, unknown> = { package_id: packageId };
if (orderId) {
payload.lemonsqueezy_order_id = orderId;
payload.paypal_order_id = orderId;
}
const response = await authorizedFetch('/api/v1/tenant/packages/complete', {

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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<CheckoutWizardPageProps> = ({
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<CheckoutWizardPageProps> = ({
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}
/>
</div>
</div>

View File

@@ -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<CheckoutWizardProps> = ({
initialStep,
googleProfile,
facebookProfile,
lemonsqueezy,
paypal,
}) => {
const [storedGoogleProfile, setStoredGoogleProfile] = useState<OAuthProfilePrefill | null>(() => {
if (typeof window === 'undefined') {
@@ -382,7 +384,7 @@ export const CheckoutWizard: React.FC<CheckoutWizardProps> = ({
initialStep={initialStep}
initialAuthUser={initialAuthUser ?? undefined}
initialIsAuthenticated={Boolean(initialAuthUser)}
lemonsqueezy={lemonsqueezy ?? null}
paypal={paypal ?? null}
>
<WizardBody
privacyHtml={privacyHtml}

View File

@@ -21,9 +21,11 @@ interface CheckoutWizardContextType {
currentStep: CheckoutStepId;
isAuthenticated: boolean;
authUser: unknown;
lemonsqueezyConfig?: {
store_id?: string | null;
test_mode?: boolean | null;
paypalConfig?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
} | null;
paymentCompleted: boolean;
checkoutSessionId: string | null;
@@ -119,9 +121,11 @@ interface CheckoutWizardProviderProps {
initialStep?: CheckoutStepId;
initialAuthUser?: unknown;
initialIsAuthenticated?: boolean;
lemonsqueezy?: {
store_id?: string | null;
test_mode?: boolean | null;
paypal?: {
client_id?: string | null;
currency?: string | null;
intent?: string | null;
locale?: string | null;
} | null;
}
@@ -132,7 +136,7 @@ export function CheckoutWizardProvider({
initialStep,
initialAuthUser,
initialIsAuthenticated,
lemonsqueezy,
paypal,
}: CheckoutWizardProviderProps) {
const customInitialState: CheckoutState = {
...initialState,
@@ -153,7 +157,7 @@ export function CheckoutWizardProvider({
if (savedState) {
try {
const parsed = JSON.parse(savedState);
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.lemonsqueezy_variant_id === 'string' && parsed.selectedPackage.lemonsqueezy_variant_id !== '';
const hasValidPackage = parsed.selectedPackage && typeof parsed.selectedPackage.id === 'number';
if (hasValidPackage && initialPackage && parsed.selectedPackage.id === initialPackage.id && parsed.currentStep !== 'confirmation') {
// Restore state selectively
if (parsed.selectedPackage) dispatch({ type: 'SELECT_PACKAGE', payload: parsed.selectedPackage });
@@ -284,7 +288,7 @@ export function CheckoutWizardProvider({
currentStep: state.currentStep,
isAuthenticated: state.isAuthenticated,
authUser: state.authUser,
lemonsqueezyConfig: lemonsqueezy ?? null,
paypalConfig: paypal ?? null,
paymentCompleted: state.paymentCompleted,
checkoutSessionId: state.checkoutSessionId,
selectPackage,

View File

@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { act, cleanup, render, screen } from '@testing-library/react';
import { CheckoutWizardProvider, useCheckoutWizard } from '../WizardContext';
import { PaymentStep } from '../steps/PaymentStep';
import { fireEvent, waitFor } from '@testing-library/react';
@@ -12,7 +12,6 @@ vi.mock('@/hooks/useAnalytics', () => ({
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<string, any> | null = null;
beforeEach(() => {
localStorage.clear();
window.LemonSqueezy = {
Setup: vi.fn(),
Url: { Open: vi.fn() },
paypalOptions = null;
window.paypal = {
Buttons: (options: Record<string, any>) => {
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(
<CheckoutWizardProvider initialPackage={basePackage} packageOptions={[basePackage]}>
<CheckoutWizardProvider
initialPackage={basePackage}
packageOptions={[basePackage]}
paypal={{ client_id: 'client', currency: 'EUR', intent: 'capture' }}
>
<PaymentStep />
</CheckoutWizardProvider>,
);
@@ -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' }}
>
<AuthSeeder />
<StepIndicator />
@@ -106,14 +128,24 @@ describe('PaymentStep', () => {
</CheckoutWizardProvider>,
);
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();
});
});

View File

@@ -19,27 +19,19 @@ import { useAnalytics } from '@/hooks/useAnalytics';
type PaymentStatus = 'idle' | 'processing' | 'ready' | 'error';
type LemonSqueezyEvent = {
event?: string;
data?: Record<string, unknown>;
};
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<string, unknown>) => {
render: (selector: HTMLElement | string) => Promise<void>;
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<typeof window.LemonSqueezy | null> | null = null;
type PayPalSdkOptions = {
clientId: string;
currency: string;
intent: string;
locale?: string | null;
};
async function loadLemonSqueezy(): Promise<typeof window.LemonSqueezy | null> {
let paypalLoaderPromise: Promise<typeof window.paypal | null> | null = null;
let paypalLoaderKey: string | null = null;
async function loadPayPalSdk(options: PayPalSdkOptions): Promise<typeof window.paypal | null> {
if (typeof window === 'undefined') {
return null;
}
if (window.LemonSqueezy) {
return window.LemonSqueezy;
if (window.paypal) {
return window.paypal;
}
if (!lemonLoaderPromise) {
lemonLoaderPromise = new Promise<typeof window.LemonSqueezy | null>((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<void>; disabled: boolean; isProcessing: boolean; className?: string }> = ({ onCheckout, disabled, isProcessing, className }) => {
const { t } = useTranslation('marketing');
return (
<Button
size="lg"
className={cn('w-full sm:w-auto', className)}
disabled={disabled}
onClick={onCheckout}
>
{isProcessing && <LoaderCircle className="mr-2 h-4 w-4 animate-spin" />}
{t('checkout.payment_step.pay_with_lemonsqueezy')}
</Button>
);
};
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<PaymentStatus>('idle');
const [message, setMessage] = useState<string>('');
const [inlineActive, setInlineActive] = useState(false);
const [acceptedTerms, setAcceptedTerms] = useState(false);
const [consentError, setConsentError] = useState<string | null>(null);
const [couponCode, setCouponCode] = useState<string>(() => {
@@ -199,10 +196,6 @@ export const PaymentStep: React.FC = () => {
const [couponError, setCouponError] = useState<string | null>(null);
const [couponNotice, setCouponNotice] = useState<string | null>(null);
const [couponLoading, setCouponLoading] = useState(false);
const lemonRef = useRef<typeof window.LemonSqueezy | null>(null);
const eventHandlerRef = useRef<(event: LemonSqueezyEvent) => void>();
const lastCheckoutIdRef = useRef<string | null>(null);
const hasAutoAppliedCoupon = useRef(false);
const [showWithdrawalModal, setShowWithdrawalModal] = useState(false);
const [withdrawalHtml, setWithdrawalHtml] = useState<string | null>(null);
const [withdrawalTitle, setWithdrawalTitle] = useState<string | null>(null);
@@ -212,10 +205,25 @@ export const PaymentStep: React.FC = () => {
const [voucherExpiry, setVoucherExpiry] = useState<string | null>(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<HTMLDivElement | null>(null);
const paypalButtonsRef = useRef<{ close?: () => void } | null>(null);
const paypalActionsRef = useRef<{ enable: () => void; disable: () => void } | null>(null);
const checkoutSessionRef = useRef<string | null>(checkoutSessionId);
const acceptedTermsRef = useRef<boolean>(acceptedTerms);
const couponCodeRef = useRef<string | null>(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<void>; 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<HTMLFormElement>) => {
event.preventDefault();
@@ -665,7 +618,6 @@ export const PaymentStep: React.FC = () => {
}
}, [checkoutLocale, t, withdrawalHtml, withdrawalLoading]);
if (!selectedPackage) {
return (
<Alert variant="destructive">
@@ -738,7 +690,6 @@ export const PaymentStep: React.FC = () => {
);
}
const TrustPill = ({ icon: Icon, label }: { icon: React.ElementType; label: string }) => (
<div className="flex items-center gap-2 rounded-full bg-white/10 px-3 py-1 text-xs font-medium text-white backdrop-blur-sm">
<Icon className="h-4 w-4 text-white/80" />
@@ -746,10 +697,10 @@ export const PaymentStep: React.FC = () => {
</div>
);
const LemonSqueezyLogo = () => (
const PayPalBadge = () => (
<div className="flex items-center gap-3 rounded-full bg-white/15 px-4 py-2 text-white shadow-inner backdrop-blur-sm">
<span className="text-sm font-semibold tracking-wide">Lemon Squeezy</span>
<span className="text-xs font-semibold">{t('checkout.payment_step.lemonsqueezy_partner')}</span>
<span className="text-sm font-semibold tracking-wide">PayPal</span>
<span className="text-xs font-semibold">{t('checkout.payment_step.paypal_partner')}</span>
</div>
);
@@ -757,11 +708,10 @@ export const PaymentStep: React.FC = () => {
<div className="space-y-4">
<div className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="space-y-6">
{!inlineActive && (
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#001835] via-[#002b55] to-[#00407c] p-6 text-white shadow-md">
<div className="overflow-hidden rounded-2xl border bg-gradient-to-br from-[#0b1f4b] via-[#003087] to-[#009cde] p-6 text-white shadow-md">
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-4">
<LemonSqueezyLogo />
<PayPalBadge />
<div className="space-y-2">
<h3 className="text-2xl font-semibold">{t('checkout.payment_step.guided_title')}</h3>
<p className="text-sm text-white/80">{t('checkout.payment_step.guided_body')}</p>
@@ -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]"
/>
<div className="space-y-1 text-sm">
<Label htmlFor="checkout-terms-hero" className="cursor-pointer text-white">
@@ -817,12 +767,7 @@ export const PaymentStep: React.FC = () => {
</div>
<div className="space-y-2">
<LemonSqueezyCta
onCheckout={startLemonSqueezyCheckout}
disabled={status === 'processing' || !acceptedTerms}
isProcessing={status === 'processing'}
className={cn('bg-white text-[#001835] hover:bg-white/90', PRIMARY_CTA_STYLES)}
/>
<div ref={paypalContainerRef} className={cn('min-h-[44px]', PRIMARY_CTA_STYLES)} />
<p className="text-xs text-white/70 text-center">
{t('checkout.payment_step.guided_cta_hint')}
</p>
@@ -831,7 +776,6 @@ export const PaymentStep: React.FC = () => {
</div>
</div>
</div>
)}
<div className="space-y-3">
<form className="flex flex-col gap-2 sm:flex-row" onSubmit={handleCouponSubmit}>
@@ -907,20 +851,6 @@ export const PaymentStep: React.FC = () => {
)}
</div>
{!inlineActive && (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{t('checkout.payment_step.lemonsqueezy_intro')}
</p>
<LemonSqueezyCta
onCheckout={startLemonSqueezyCheckout}
disabled={status === 'processing' || !acceptedTerms}
isProcessing={status === 'processing'}
className={PRIMARY_CTA_STYLES}
/>
</div>
)}
{status !== 'idle' && (
<Alert variant={status === 'error' ? 'destructive' : 'default'}>
<AlertTitle>
@@ -939,8 +869,8 @@ export const PaymentStep: React.FC = () => {
</Alert>
)}
<p className={`text-xs text-muted-foreground ${inlineActive ? 'text-center' : 'sm:text-right'}`}>
{t('checkout.payment_step.lemonsqueezy_disclaimer')}
<p className="text-xs text-muted-foreground sm:text-right">
{t('checkout.payment_step.paypal_disclaimer')}
</p>
</div>
</div>

View File

@@ -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."
}
}
}

View File

@@ -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' => [

View File

@@ -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."
}
}
}

View File

@@ -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' => [

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -1,70 +0,0 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Package;
use App\Services\LemonSqueezy\LemonSqueezyCheckoutService;
use Mockery;
class TenantLemonSqueezyCheckoutTest extends TenantTestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_tenant_can_create_lemonsqueezy_checkout(): void
{
$package = Package::factory()->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' => [],
],
]);
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace Tests\Feature\Tenant;
use App\Models\Package;
use App\Services\PayPal\PayPalOrderService;
use Mockery;
class TenantPayPalCheckoutTest extends TenantTestCase
{
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
public function test_tenant_can_create_paypal_checkout(): void
{
$package = Package::factory()->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' => [],
],
]);
}
}