wenn checkout.completed kommt, senden wir jetzt transaction_id +
checkout_id direkt an das Backend, damit der Server die Session via Paddle‑API finalisiert (auch wenn der Webhook nicht greift). Dadurch sollte “Zahlung wird verarbeitet” nicht mehr hängen bleiben.
This commit is contained in:
@@ -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([
|
||||
|
||||
50
app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php
Normal file
50
app/Http/Requests/Checkout/CheckoutSessionConfirmRequest.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Checkout;
|
||||
|
||||
use App\Models\CheckoutSession;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckoutSessionConfirmRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
$session = $this->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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ export const PaymentStep: React.FC = () => {
|
||||
const [confirmationElapsedMs, setConfirmationElapsedMs] = useState(0);
|
||||
const confirmationTimerRef = useRef<number | null>(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<string, unknown>) => {
|
||||
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<string, unknown>);
|
||||
}
|
||||
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(() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user