From c0c98abbc7a4b13564f199a5b54988ee002c2a51 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Mon, 22 Dec 2025 14:45:51 +0100 Subject: [PATCH] =?UTF-8?q?wenn=20checkout.completed=20kommt,=20senden=20w?= =?UTF-8?q?ir=20jetzt=20transaction=5Fid=20+=20=20=20checkout=5Fid=20direk?= =?UTF-8?q?t=20an=20das=20Backend,=20damit=20der=20Server=20die=20Session?= =?UTF-8?q?=20via=20Paddle=E2=80=91API=20finalisiert=20(auch=20wenn=20der?= =?UTF-8?q?=20Webhook=20=20=20nicht=20greift).=20Dadurch=20sollte=20?= =?UTF-8?q?=E2=80=9CZahlung=20wird=20verarbeitet=E2=80=9D=20nicht=20mehr?= =?UTF-8?q?=20h=C3=A4ngen=20bleiben.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/CheckoutController.php | 42 +++++++++++++ .../CheckoutSessionConfirmRequest.php | 50 +++++++++++++++ .../marketing/checkout/steps/PaymentStep.tsx | 33 ++++++++++ routes/web.php | 3 + .../Checkout/CheckoutSessionStatusTest.php | 61 +++++++++++++++++++ 5 files changed, 189 insertions(+) create mode 100644 app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php index 6a7c481..9c3372f 100644 --- a/app/Http/Controllers/CheckoutController.php +++ b/app/Http/Controllers/CheckoutController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Http\Requests\Checkout\CheckoutFreeActivationRequest; use App\Http\Requests\Checkout\CheckoutLoginRequest; use App\Http\Requests\Checkout\CheckoutRegisterRequest; +use App\Http\Requests\Checkout\CheckoutSessionConfirmRequest; use App\Http\Requests\Checkout\CheckoutSessionStatusRequest; use App\Mail\Welcome; use App\Models\AbandonedCheckout; @@ -268,6 +269,47 @@ class CheckoutController extends Controller ]); } + public function confirmSession( + CheckoutSessionConfirmRequest $request, + CheckoutSession $session, + CheckoutSessionService $sessions, + CheckoutAssignmentService $assignment, + PaddleTransactionService $transactions, + ): JsonResponse { + $validated = $request->validated(); + $transactionId = $validated['transaction_id'] ?? null; + $checkoutId = $validated['checkout_id'] ?? null; + + $metadata = $session->provider_metadata ?? []; + $metadataUpdated = false; + + if ($transactionId) { + $session->paddle_transaction_id = $transactionId; + $metadata['paddle_transaction_id'] = $transactionId; + $metadataUpdated = true; + } + + if ($checkoutId) { + $metadata['paddle_checkout_id'] = $checkoutId; + $metadataUpdated = true; + } + + if ($metadataUpdated) { + $metadata['paddle_client_event_at'] = now()->toIso8601String(); + $session->provider_metadata = $metadata; + $session->save(); + } + + $this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions); + + $session->refresh(); + + return response()->json([ + 'status' => $session->status, + 'completed_at' => optional($session->completed_at)->toIso8601String(), + ]); + } + public function trackAbandonedCheckout(Request $request) { $validated = $request->validate([ diff --git a/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php b/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php new file mode 100644 index 0000000..b875a2a --- /dev/null +++ b/app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php @@ -0,0 +1,50 @@ +route('session'); + + if (! $session instanceof CheckoutSession) { + return false; + } + + $user = $this->user(); + + if (! $user) { + return false; + } + + return (int) $session->user_id === (int) $user->id; + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return [ + 'transaction_id' => ['nullable', 'string', 'required_without:checkout_id'], + 'checkout_id' => ['nullable', 'string', 'required_without:transaction_id'], + ]; + } + + public function messages(): array + { + return [ + 'transaction_id.required_without' => 'Transaction ID oder Checkout ID fehlt.', + 'checkout_id.required_without' => 'Checkout ID oder Transaction ID fehlt.', + ]; + } +} diff --git a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx index e98b2d6..e1b9c88 100644 --- a/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx +++ b/resources/js/pages/marketing/checkout/steps/PaymentStep.tsx @@ -202,6 +202,7 @@ export const PaymentStep: React.FC = () => { const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0); const confirmationTimerRef = useRef(null); const statusCheckRef = useRef<(() => void) | null>(null); + const confirmRequestRef = useRef(false); const paddleLocale = useMemo(() => { const sourceLocale = i18n.language || (typeof document !== 'undefined' ? document.documentElement.lang : null); @@ -210,6 +211,33 @@ export const PaymentStep: React.FC = () => { const isFree = useMemo(() => (selectedPackage ? Number(selectedPackage.price) <= 0 : false), [selectedPackage]); + const confirmCheckoutSession = useCallback(async (payload: Record) => { + if (!checkoutSessionId) { + return; + } + + const transactionId = typeof payload?.transaction_id === 'string' ? payload.transaction_id : null; + const checkoutId = typeof payload?.id === 'string' ? payload.id : null; + + if (!transactionId && !checkoutId) { + return; + } + + try { + await fetch(`/checkout/session/${checkoutSessionId}/confirm`, { + method: 'POST', + headers: buildCheckoutHeaders(), + credentials: 'same-origin', + body: JSON.stringify({ + transaction_id: transactionId, + checkout_id: checkoutId, + }), + }); + } catch (error) { + console.warn('Failed to confirm Paddle session', error); + } + }, [checkoutSessionId]); + const applyCoupon = useCallback(async (code: string) => { if (!selectedPackage) { return; @@ -521,6 +549,10 @@ export const PaymentStep: React.FC = () => { setInlineActive(false); setAwaitingConfirmation(true); setPaymentCompleted(false); + if (!confirmRequestRef.current) { + confirmRequestRef.current = true; + void confirmCheckoutSession(event.data as Record); + } toast.success(t('checkout.payment_step.toast_success')); } @@ -596,6 +628,7 @@ export const PaymentStep: React.FC = () => { setInlineActive(false); setAwaitingConfirmation(false); setConfirmationElapsedMs(0); + confirmRequestRef.current = false; }, [selectedPackage?.id, setPaymentCompleted]); useEffect(() => { diff --git a/routes/web.php b/routes/web.php index 5b53051..2063d0d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -370,6 +370,9 @@ Route::middleware('auth')->group(function () { Route::get('/checkout/session/{session}/status', [CheckoutController::class, 'sessionStatus']) ->whereUuid('session') ->name('checkout.session.status'); + Route::post('/checkout/session/{session}/confirm', [CheckoutController::class, 'confirmSession']) + ->whereUuid('session') + ->name('checkout.session.confirm'); Route::post('/paddle/create-checkout', [PaddleCheckoutController::class, 'create'])->name('paddle.checkout.create'); }); diff --git a/tests/Feature/Checkout/CheckoutSessionStatusTest.php b/tests/Feature/Checkout/CheckoutSessionStatusTest.php index 99b87ad..9395aa1 100644 --- a/tests/Feature/Checkout/CheckoutSessionStatusTest.php +++ b/tests/Feature/Checkout/CheckoutSessionStatusTest.php @@ -127,4 +127,65 @@ class CheckoutSessionStatusTest extends TestCase 'package_id' => $package->id, ]); } + + public function test_session_confirm_recovers_completed_paddle_transaction(): void + { + $tenant = Tenant::factory()->create(); + $user = User::factory()->for($tenant)->create([ + 'pending_purchase' => true, + ]); + $package = Package::factory()->create([ + 'type' => 'endcustomer', + 'price' => 79, + ]); + + /** @var CheckoutSessionService $sessions */ + $sessions = app(CheckoutSessionService::class); + $session = $sessions->createOrResume($user, $package, [ + 'tenant' => $tenant, + ]); + $sessions->selectProvider($session, CheckoutSession::PROVIDER_PADDLE); + + config()->set([ + 'paddle.api_key' => 'test-key', + 'paddle.base_url' => 'https://paddle.test', + 'paddle.environment' => 'sandbox', + ]); + + Http::fake([ + 'https://paddle.test/transactions/txn_987' => Http::response([ + 'data' => [ + 'id' => 'txn_987', + 'status' => 'completed', + 'details' => [ + 'totals' => [ + 'currency_code' => 'EUR', + 'total' => ['amount' => 7900], + ], + ], + 'custom_data' => [ + 'checkout_session_id' => $session->id, + ], + ], + ], 200), + ]); + + Mail::fake(); + Notification::fake(); + + $this->actingAs($user); + + $response = $this->postJson(route('checkout.session.confirm', $session), [ + 'transaction_id' => 'txn_987', + 'checkout_id' => 'che_987', + ]); + + $response->assertOk() + ->assertJsonPath('status', CheckoutSession::STATUS_COMPLETED); + + $this->assertDatabaseHas('checkout_sessions', [ + 'id' => $session->id, + 'status' => CheckoutSession::STATUS_COMPLETED, + ]); + } }