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.
531 lines
18 KiB
PHP
531 lines
18 KiB
PHP
<?php
|
|
|
|
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;
|
|
use App\Models\CheckoutSession;
|
|
use App\Models\Package;
|
|
use App\Models\Tenant;
|
|
use App\Models\User;
|
|
use App\Services\Checkout\CheckoutAssignmentService;
|
|
use App\Services\Checkout\CheckoutSessionService;
|
|
use App\Services\Paddle\Exceptions\PaddleException;
|
|
use App\Services\Paddle\PaddleTransactionService;
|
|
use App\Support\CheckoutRoutes;
|
|
use App\Support\Concerns\PresentsPackages;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Facades\Log;
|
|
use Illuminate\Support\Facades\Mail;
|
|
use Illuminate\Support\Str;
|
|
use Inertia\Inertia;
|
|
|
|
class CheckoutController extends Controller
|
|
{
|
|
use PresentsPackages;
|
|
|
|
public function show(string $locale, string $checkoutSlug, string $package): \Inertia\Response|\Illuminate\Http\RedirectResponse
|
|
{
|
|
$resolvedPackage = Package::query()->find($package);
|
|
|
|
if (! $resolvedPackage) {
|
|
return redirect()
|
|
->route('packages', ['locale' => $locale])
|
|
->with('error', __('marketing.packages.package_not_found'));
|
|
}
|
|
|
|
$googleStatus = session()->pull('checkout_google_status');
|
|
$googleError = session()->pull('checkout_google_error');
|
|
$googleProfile = session()->pull('checkout_google_profile');
|
|
|
|
$packageOptions = Package::orderBy('price')->get()
|
|
->map(fn (Package $pkg) => $this->presentPackage($pkg))
|
|
->values()
|
|
->all();
|
|
|
|
return Inertia::render('marketing/CheckoutWizardPage', [
|
|
'package' => $this->presentPackage($resolvedPackage),
|
|
'packageOptions' => $packageOptions,
|
|
'privacyHtml' => view('legal.datenschutz-partial')->render(),
|
|
'auth' => [
|
|
'user' => Auth::user(),
|
|
],
|
|
'googleAuth' => [
|
|
'status' => $googleStatus,
|
|
'error' => $googleError,
|
|
'profile' => $googleProfile,
|
|
],
|
|
'paddle' => [
|
|
'environment' => config('paddle.environment'),
|
|
'client_token' => config('paddle.client_token'),
|
|
],
|
|
]);
|
|
}
|
|
|
|
public function register(CheckoutRegisterRequest $request): JsonResponse
|
|
{
|
|
$validated = $request->validated();
|
|
$package = Package::findOrFail($validated['package_id']);
|
|
$user = DB::transaction(function () use ($package, $validated) {
|
|
|
|
// User erstellen
|
|
$user = User::create([
|
|
'email' => $validated['email'],
|
|
'username' => $validated['username'],
|
|
'first_name' => $validated['first_name'],
|
|
'last_name' => $validated['last_name'],
|
|
'name' => trim($validated['first_name'].' '.$validated['last_name']),
|
|
'address' => $validated['address'],
|
|
'phone' => $validated['phone'],
|
|
'preferred_locale' => $validated['locale'] ?? null,
|
|
'password' => Hash::make($validated['password']),
|
|
'pending_purchase' => true,
|
|
]);
|
|
|
|
// Tenant erstellen
|
|
$tenant = Tenant::create([
|
|
'user_id' => $user->id,
|
|
'name' => $validated['first_name'].' '.$validated['last_name'],
|
|
'slug' => Str::slug($validated['first_name'].' '.$validated['last_name'].'-'.now()->timestamp),
|
|
'email' => $validated['email'],
|
|
'is_active' => true,
|
|
'is_suspended' => false,
|
|
'subscription_tier' => 'free',
|
|
'subscription_expires_at' => null,
|
|
'settings' => json_encode([
|
|
'branding' => [
|
|
'logo_url' => null,
|
|
'primary_color' => '#3B82F6',
|
|
'secondary_color' => '#1F2937',
|
|
'font_family' => 'Inter, sans-serif',
|
|
],
|
|
'features' => [
|
|
'photo_likes_enabled' => false,
|
|
'event_checklist' => false,
|
|
'custom_domain' => false,
|
|
'advanced_analytics' => false,
|
|
],
|
|
'custom_domain' => null,
|
|
'contact_email' => $validated['email'],
|
|
'event_default_type' => 'general',
|
|
]),
|
|
]);
|
|
|
|
$user->forceFill(['tenant_id' => $tenant->id])->save();
|
|
// Package zuweisen
|
|
$tenant->packages()->attach($package->id, [
|
|
'price' => $package->price,
|
|
'purchased_at' => now(),
|
|
'expires_at' => $this->packageIsFree($package) ? now()->addYear() : now()->addYear(),
|
|
'active' => $this->packageIsFree($package), // Kostenlose Pakete sofort aktivieren
|
|
]);
|
|
|
|
// E-Mail-Verifizierung senden
|
|
$user->sendEmailVerificationNotification();
|
|
|
|
// Willkommens-E-Mail senden
|
|
Mail::to($user)
|
|
->locale($user->preferred_locale ?? app()->getLocale())
|
|
->queue(new Welcome($user));
|
|
|
|
return $user;
|
|
});
|
|
|
|
Auth::login($user);
|
|
$request->session()->put('checkout.pending_package_id', $package->id);
|
|
$redirectUrl = CheckoutRoutes::wizardUrl($package, $validated['locale'] ?? null);
|
|
$request->session()->put('checkout.verify_redirect', $redirectUrl);
|
|
$request->session()->put('url.intended', $redirectUrl);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => 'Registrierung erfolgreich. Bitte überprüfen Sie Ihre E-Mail zur Verifizierung.',
|
|
'redirect' => $redirectUrl,
|
|
'user' => [
|
|
'id' => $user->id,
|
|
'email' => $user->email,
|
|
'name' => $user->name ?? trim($user->first_name.' '.$user->last_name),
|
|
'pending_purchase' => $user->pending_purchase ?? true,
|
|
'email_verified_at' => $user->email_verified_at,
|
|
],
|
|
'pending_purchase' => $user->pending_purchase ?? true,
|
|
]);
|
|
}
|
|
|
|
public function login(CheckoutLoginRequest $request): JsonResponse
|
|
{
|
|
$validated = $request->validated();
|
|
$packageId = $validated['package_id'] ?? $request->session()->get('selected_package_id');
|
|
if ($packageId) {
|
|
$request->session()->put('selected_package_id', $packageId);
|
|
}
|
|
|
|
// Custom Auth für Identifier (E-Mail oder Username)
|
|
$identifier = $validated['identifier'];
|
|
$user = User::where('email', $identifier)
|
|
->orWhere('username', $identifier)
|
|
->first();
|
|
|
|
if (! $user || ! Hash::check($validated['password'], $user->password)) {
|
|
return response()->json([
|
|
'errors' => ['identifier' => ['Ungültige Anmeldedaten.']],
|
|
], 422);
|
|
}
|
|
|
|
Auth::login($user, $request->boolean('remember'));
|
|
$request->session()->regenerate();
|
|
|
|
// Checkout-spezifische Logik
|
|
DB::transaction(function () use ($request, $user, $packageId) {
|
|
if ($packageId && ! $user->pending_purchase) {
|
|
$user->update(['pending_purchase' => true]);
|
|
$request->session()->put('pending_package_id', $packageId);
|
|
}
|
|
});
|
|
|
|
return response()->json([
|
|
'user' => [
|
|
'id' => $user->id,
|
|
'email' => $user->email,
|
|
'name' => $user->name ?? null,
|
|
'pending_purchase' => $user->pending_purchase ?? false,
|
|
],
|
|
'message' => 'Login erfolgreich',
|
|
]);
|
|
}
|
|
|
|
public function activateFree(
|
|
CheckoutFreeActivationRequest $request,
|
|
CheckoutSessionService $sessions,
|
|
CheckoutAssignmentService $assignment,
|
|
): JsonResponse {
|
|
$validated = $request->validated();
|
|
|
|
$user = $request->user();
|
|
if (! $user) {
|
|
return response()->json(['message' => 'Unauthenticated.'], 401);
|
|
}
|
|
|
|
$package = Package::findOrFail($validated['package_id']);
|
|
|
|
if ($package->price > 0) {
|
|
return response()->json([
|
|
'message' => 'Package is not eligible for free activation.',
|
|
], 422);
|
|
}
|
|
|
|
$session = $sessions->createOrResume($user, $package, [
|
|
'tenant' => $user->tenant,
|
|
'locale' => $validated['locale'] ?? null,
|
|
]);
|
|
|
|
$sessions->selectProvider($session, CheckoutSession::PROVIDER_FREE);
|
|
|
|
$now = now();
|
|
$session->forceFill([
|
|
'accepted_terms_at' => $now,
|
|
'accepted_privacy_at' => $now,
|
|
'accepted_withdrawal_notice_at' => $now,
|
|
'digital_content_waiver_at' => null,
|
|
'legal_version' => config('app.legal_version', $now->toDateString()),
|
|
])->save();
|
|
|
|
$assignment->finalise($session, [
|
|
'provider' => CheckoutSession::PROVIDER_FREE,
|
|
]);
|
|
|
|
$sessions->markCompleted($session, $now);
|
|
|
|
return response()->json([
|
|
'status' => 'completed',
|
|
'checkout_session_id' => $session->id,
|
|
]);
|
|
}
|
|
|
|
public function sessionStatus(
|
|
CheckoutSessionStatusRequest $request,
|
|
CheckoutSession $session,
|
|
CheckoutSessionService $sessions,
|
|
CheckoutAssignmentService $assignment,
|
|
PaddleTransactionService $transactions,
|
|
): JsonResponse {
|
|
$this->attemptPaddleRecovery($session, $sessions, $assignment, $transactions);
|
|
|
|
$session->refresh();
|
|
|
|
return response()->json([
|
|
'status' => $session->status,
|
|
'completed_at' => optional($session->completed_at)->toIso8601String(),
|
|
]);
|
|
}
|
|
|
|
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([
|
|
'package_id' => 'required|exists:packages,id',
|
|
'email' => 'nullable|email',
|
|
'step' => 'nullable|string|in:package,auth,payment,confirmation',
|
|
'checkout_state' => 'nullable|array',
|
|
]);
|
|
|
|
$user = Auth::user();
|
|
|
|
if (! $user && ! empty($validated['email'])) {
|
|
$user = User::where('email', $validated['email'])->first();
|
|
}
|
|
|
|
if (! $user) {
|
|
return response()->json(['status' => 'skipped'], 202);
|
|
}
|
|
|
|
$package = Package::find($validated['package_id']);
|
|
if (! $package) {
|
|
return response()->json(['status' => 'missing_package'], 404);
|
|
}
|
|
|
|
$stepMap = [
|
|
'package' => 1,
|
|
'auth' => 2,
|
|
'payment' => 3,
|
|
'confirmation' => 4,
|
|
];
|
|
|
|
$lastStep = $stepMap[$validated['step'] ?? 'package'] ?? 1;
|
|
|
|
$checkout = AbandonedCheckout::firstOrNew([
|
|
'user_id' => $user->id,
|
|
'package_id' => $package->id,
|
|
]);
|
|
|
|
$checkout->email = $user->email;
|
|
$checkout->checkout_state = $validated['checkout_state'] ?? [
|
|
'step' => $validated['step'] ?? 'package',
|
|
];
|
|
$checkout->last_step = $lastStep;
|
|
$checkout->abandoned_at = now();
|
|
|
|
if (! $checkout->exists || $checkout->converted) {
|
|
$checkout->reminder_stage = 'none';
|
|
$checkout->reminded_at = null;
|
|
}
|
|
|
|
if (! $checkout->expires_at || $checkout->expires_at->isPast()) {
|
|
$checkout->expires_at = now()->addDays(30);
|
|
}
|
|
|
|
$checkout->converted = false;
|
|
$checkout->save();
|
|
|
|
return response()->json(['status' => 'tracked']);
|
|
}
|
|
|
|
private function packageIsFree(Package $package): bool
|
|
{
|
|
if (isset($package->is_free)) {
|
|
return (bool) $package->is_free;
|
|
}
|
|
|
|
$price = (float) $package->price;
|
|
|
|
return $price <= 0;
|
|
}
|
|
|
|
private function attemptPaddleRecovery(
|
|
CheckoutSession $session,
|
|
CheckoutSessionService $sessions,
|
|
CheckoutAssignmentService $assignment,
|
|
PaddleTransactionService $transactions
|
|
): void {
|
|
if ($session->provider !== CheckoutSession::PROVIDER_PADDLE) {
|
|
return;
|
|
}
|
|
|
|
if (in_array($session->status, [
|
|
CheckoutSession::STATUS_COMPLETED,
|
|
CheckoutSession::STATUS_FAILED,
|
|
CheckoutSession::STATUS_CANCELLED,
|
|
], true)) {
|
|
return;
|
|
}
|
|
|
|
$metadata = $session->provider_metadata ?? [];
|
|
$lastPollAt = $metadata['paddle_poll_at'] ?? null;
|
|
$now = now();
|
|
|
|
if ($lastPollAt) {
|
|
try {
|
|
$lastPoll = Carbon::parse($lastPollAt);
|
|
if ($lastPoll->diffInSeconds($now) < 15) {
|
|
return;
|
|
}
|
|
} catch (\Throwable) {
|
|
// Ignore invalid timestamps.
|
|
}
|
|
}
|
|
|
|
$checkoutId = $metadata['paddle_checkout_id'] ?? $session->paddle_checkout_id ?? null;
|
|
$transactionId = $metadata['paddle_transaction_id'] ?? $session->paddle_transaction_id ?? null;
|
|
|
|
if (! $checkoutId && ! $transactionId) {
|
|
Log::info('[Checkout] Paddle recovery missing checkout reference, falling back to custom data scan', [
|
|
'session_id' => $session->id,
|
|
]);
|
|
}
|
|
|
|
$metadata['paddle_poll_at'] = $now->toIso8601String();
|
|
$session->forceFill([
|
|
'provider_metadata' => $metadata,
|
|
])->save();
|
|
|
|
try {
|
|
$transaction = $transactionId ? $transactions->retrieve($transactionId) : null;
|
|
|
|
if (! $transaction && $checkoutId) {
|
|
$transaction = $transactions->findByCheckoutId($checkoutId);
|
|
}
|
|
|
|
if (! $transaction) {
|
|
$transaction = $transactions->findByCustomData([
|
|
'checkout_session_id' => $session->id,
|
|
'package_id' => (string) $session->package_id,
|
|
'tenant_id' => (string) $session->tenant_id,
|
|
]);
|
|
}
|
|
} catch (PaddleException $exception) {
|
|
Log::warning('[Checkout] Paddle recovery failed', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
'status' => $exception->status(),
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
|
|
return;
|
|
} catch (\Throwable $exception) {
|
|
Log::warning('[Checkout] Paddle recovery failed', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
'message' => $exception->getMessage(),
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
if (! $transaction) {
|
|
Log::info('[Checkout] Paddle recovery: transaction not found', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
$status = strtolower((string) ($transaction['status'] ?? ''));
|
|
$transactionId = $transactionId ?: ($transaction['id'] ?? null);
|
|
|
|
if ($transactionId && $session->paddle_transaction_id !== $transactionId) {
|
|
$session->forceFill([
|
|
'paddle_transaction_id' => $transactionId,
|
|
])->save();
|
|
}
|
|
|
|
if ($status === 'completed') {
|
|
$sessions->markProcessing($session, [
|
|
'paddle_status' => $status,
|
|
'paddle_transaction_id' => $transactionId,
|
|
'paddle_recovered_at' => $now->toIso8601String(),
|
|
]);
|
|
|
|
$assignment->finalise($session, [
|
|
'source' => 'paddle_poll',
|
|
'provider' => CheckoutSession::PROVIDER_PADDLE,
|
|
'provider_reference' => $transactionId,
|
|
'payload' => $transaction,
|
|
]);
|
|
|
|
$sessions->markCompleted($session, $now);
|
|
|
|
Log::info('[Checkout] Paddle session recovered via API', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
if (in_array($status, ['failed', 'cancelled', 'canceled'], true)) {
|
|
$sessions->markFailed($session, 'paddle_'.$status);
|
|
|
|
Log::info('[Checkout] Paddle transaction failed', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
'status' => $status,
|
|
]);
|
|
|
|
return;
|
|
}
|
|
|
|
Log::info('[Checkout] Paddle transaction pending', [
|
|
'session_id' => $session->id,
|
|
'checkout_id' => $checkoutId,
|
|
'transaction_id' => $transactionId,
|
|
'status' => $status,
|
|
]);
|
|
}
|
|
}
|