Add PayPal checkout provider
This commit is contained in:
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
223
app/Http/Controllers/PayPalCheckoutController.php
Normal file
223
app/Http/Controllers/PayPalCheckoutController.php
Normal 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());
|
||||
}
|
||||
}
|
||||
136
app/Http/Controllers/PayPalReturnController.php
Normal file
136
app/Http/Controllers/PayPalReturnController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
56
app/Http/Requests/PayPal/PayPalCaptureRequest.php
Normal file
56
app/Http/Requests/PayPal/PayPalCaptureRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
41
app/Http/Requests/PayPal/PayPalCheckoutRequest.php
Normal file
41
app/Http/Requests/PayPal/PayPalCheckoutRequest.php
Normal 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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ class CheckoutSession extends Model
|
||||
|
||||
public const PROVIDER_LEMONSQUEEZY = 'lemonsqueezy';
|
||||
|
||||
public const PROVIDER_PAYPAL = 'paypal';
|
||||
|
||||
public const PROVIDER_FREE = 'free';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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}]");
|
||||
}
|
||||
|
||||
|
||||
23
app/Services/PayPal/Exceptions/PayPalException.php
Normal file
23
app/Services/PayPal/Exceptions/PayPalException.php
Normal 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;
|
||||
}
|
||||
}
|
||||
145
app/Services/PayPal/PayPalClient.php
Normal file
145
app/Services/PayPal/PayPalClient.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
161
app/Services/PayPal/PayPalOrderService.php
Normal file
161
app/Services/PayPal/PayPalOrderService.php
Normal 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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
"faq_q3": "What happens when it expires?",
|
||||
"faq_a3": "The gallery remains readable, but uploads are blocked. Simply extend.",
|
||||
"faq_q4": "Payment secure?",
|
||||
"faq_a4": "Yes, via Lemon Squeezy – secure and GDPR compliant.",
|
||||
"faq_a4": "Yes, via PayPal - secure and GDPR-compliant.",
|
||||
"final_cta": "Ready for your next event?",
|
||||
"contact_us": "Contact Us",
|
||||
"feature_live_slideshow": "Live Slideshow",
|
||||
@@ -167,7 +167,7 @@
|
||||
"billing_per_bundle": "per bundle",
|
||||
"more_features": "+{{count}} more features",
|
||||
"feature_overview": "Feature overview",
|
||||
"order_hint": "Launch instantly – secure Lemon Squeezy checkout, no hidden fees.",
|
||||
"order_hint": "Ready to launch instantly - secure PayPal checkout, no hidden fees.",
|
||||
"features_label": "Features",
|
||||
"feature_highlights": "Feature Highlights",
|
||||
"detail_labels": {
|
||||
@@ -353,9 +353,9 @@
|
||||
"currency": {
|
||||
"euro": "€"
|
||||
},
|
||||
"lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.",
|
||||
"lemonsqueezy_checkout_failed": "We could not start the Lemon Squeezy checkout. Please try again later.",
|
||||
"gift_cta": "Gift a package"
|
||||
"lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.",
|
||||
"lemonsqueezy_checkout_failed": "We could not start the PayPal checkout. Please try again later.",
|
||||
"paypal_checkout_failed": "We could not start the PayPal checkout. Please try again later."
|
||||
},
|
||||
"blog": {
|
||||
"title": "Fotospiel - Blog",
|
||||
@@ -521,7 +521,6 @@
|
||||
"language_de": "Deutsch",
|
||||
"language_en": "English",
|
||||
"language_changed": "{{language}} selected",
|
||||
"language": "Language",
|
||||
"open_menu": "Open menu",
|
||||
"close_menu": "Close menu",
|
||||
"cta_demo": "Try it now",
|
||||
@@ -683,24 +682,24 @@
|
||||
"free_package_desc": "This package is free. We activate it directly after confirmation.",
|
||||
"activate_package": "Activate Package",
|
||||
"loading_payment": "Payment data is loading...",
|
||||
"secure_payment_desc": "Secure payment with Lemon Squeezy.",
|
||||
"lemonsqueezy_intro": "Launch the Lemon Squeezy checkout right here in the wizard—no page changes required.",
|
||||
"guided_title": "Secure checkout, powered by Lemon Squeezy",
|
||||
"guided_body": "We walk you through every step. Lemon Squeezy acts as merchant of record, handles taxes automatically, and delivers compliant invoices instantly.",
|
||||
"lemonsqueezy_partner": "Powered by Lemon Squeezy",
|
||||
"secure_payment_desc": "Secure payment with PayPal.",
|
||||
"lemonsqueezy_intro": "Start the PayPal checkout right here in the wizard - no page changes required.",
|
||||
"guided_title": "Secure checkout with PayPal",
|
||||
"guided_body": "Pay quickly and securely with PayPal. Your package unlocks immediately after confirmation.",
|
||||
"lemonsqueezy_partner": "Powered by PayPal",
|
||||
"trust_secure": "Encrypted payment",
|
||||
"trust_tax": "Automatic tax handling",
|
||||
"trust_support": "Live support within minutes",
|
||||
"guided_cta_hint": "Securely processed by Lemon Squeezy as Merchant of Record",
|
||||
"guided_cta_hint": "Securely processed via PayPal",
|
||||
"toast_success": "Payment received – setting everything up for you.",
|
||||
"lemonsqueezy_preparing": "Preparing Lemon Squeezy checkout…",
|
||||
"lemonsqueezy_overlay_ready": "Lemon Squeezy checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||
"lemonsqueezy_ready": "Lemon Squeezy checkout opened in a new tab. Complete the payment and then continue here.",
|
||||
"lemonsqueezy_error": "We could not start the Lemon Squeezy checkout. Please try again.",
|
||||
"lemonsqueezy_not_ready": "Lemon Squeezy checkout is not ready yet. Please try again in a moment.",
|
||||
"lemonsqueezy_not_configured": "This package is not ready for Lemon Squeezy checkout. Please contact support.",
|
||||
"lemonsqueezy_disclaimer": "Lemon Squeezy processes payments as merchant of record. Taxes are calculated automatically based on your billing details.",
|
||||
"pay_with_lemonsqueezy": "Continue with Lemon Squeezy",
|
||||
"lemonsqueezy_preparing": "Preparing PayPal checkout...",
|
||||
"lemonsqueezy_overlay_ready": "PayPal checkout is running in a secure overlay. Complete the payment there and then continue here.",
|
||||
"lemonsqueezy_ready": "PayPal checkout opened in a new tab. Complete the payment and then continue here.",
|
||||
"lemonsqueezy_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"lemonsqueezy_not_ready": "PayPal checkout is not ready yet. Please try again in a moment.",
|
||||
"lemonsqueezy_not_configured": "This package is not ready for PayPal checkout. Please contact support.",
|
||||
"lemonsqueezy_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase.",
|
||||
"pay_with_lemonsqueezy": "Continue with PayPal",
|
||||
"continue_after_payment": "I completed the payment",
|
||||
"no_package_title": "No package selected",
|
||||
"no_package_description": "Please choose a package to continue to checkout.",
|
||||
@@ -734,7 +733,14 @@
|
||||
"processing_manual_hint": "If this takes too long, try again or refresh the page.",
|
||||
"processing_retry": "Retry status check",
|
||||
"processing_refresh": "Refresh page",
|
||||
"processing_confirmation": "Payment received. Finalising your order..."
|
||||
"processing_confirmation": "Payment received. Finalising your order...",
|
||||
"paypal_partner": "Powered by PayPal",
|
||||
"paypal_preparing": "Preparing PayPal checkout...",
|
||||
"paypal_ready": "PayPal checkout is ready. Complete the payment to continue.",
|
||||
"paypal_error": "We could not start the PayPal checkout. Please try again.",
|
||||
"paypal_not_configured": "PayPal checkout is not configured yet. Please contact support.",
|
||||
"paypal_cancelled": "PayPal checkout was cancelled.",
|
||||
"paypal_disclaimer": "Payments are processed securely by PayPal. You will receive a receipt after purchase."
|
||||
},
|
||||
"confirmation_step": {
|
||||
"title": "Confirmation",
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' => [
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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' => [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
70
tests/Feature/Tenant/TenantPayPalCheckoutTest.php
Normal file
70
tests/Feature/Tenant/TenantPayPalCheckoutTest.php
Normal 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' => [],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user