From 8950988eac6545db2b5b4725b1c31a03dfb068a8 Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:59:09 -0800 Subject: [PATCH] feat(payment): implement beautiful payment confirmation page with real-time status checking - Add PaymentSuccessController with authentication and subscription selection logic - Create PaymentConfirmation Livewire component with polling mechanism - Implement real-time subscription status verification via Polar provider API - Add confetti animation for successful payment confirmation - Design responsive payment success page with dark mode support - Fix Polar provider field mapping (updated_at -> modified_at) - Add comprehensive error handling and logging - Support multiple subscription status states (verifying, activated, pending, error) - Implement automatic polling with 30-second intervals (max 5 attempts) - Add fallback redirects and user-friendly status messages --- .../Controllers/PaymentSuccessController.php | 64 +++++ app/Livewire/PaymentConfirmation.php | 254 +++++++++++++++++ app/Services/Payments/PaymentOrchestrator.php | 59 ++++ .../Payments/Providers/PolarProvider.php | 11 +- .../livewire/payment-confirmation.blade.php | 259 ++++++++++++++++++ resources/views/payment/success.blade.php | 131 +++++++++ routes/payment.php | 5 +- 7 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/PaymentSuccessController.php create mode 100644 app/Livewire/PaymentConfirmation.php create mode 100644 resources/views/livewire/payment-confirmation.blade.php create mode 100644 resources/views/payment/success.blade.php diff --git a/app/Http/Controllers/PaymentSuccessController.php b/app/Http/Controllers/PaymentSuccessController.php new file mode 100644 index 0000000..ff88301 --- /dev/null +++ b/app/Http/Controllers/PaymentSuccessController.php @@ -0,0 +1,64 @@ +user(); + $sessionToken = $request->get('customer_session_token'); + + Log::info('PaymentSuccessController: Showing payment confirmation', [ + 'user_id' => $user->id, + 'session_token' => $sessionToken, + ]); + + // Get the most recent subscription for this user created in the last 15 minutes + // This ensures we're checking the subscription from the current payment session + $recentMinutes = 15; + $subscription = Subscription::where('user_id', $user->id) + ->where('created_at', '>=', now()->subMinutes($recentMinutes)) + ->whereIn('status', ['pending_payment', 'incomplete', 'trialing', 'active']) // Likely statuses for new subscriptions + ->orderBy('created_at', 'desc') + ->first(); + + // If no recent subscription found, fall back to the most recent one overall + if (! $subscription) { + Log::info('PaymentSuccessController: No recent subscription found, falling back to most recent', [ + 'user_id' => $user->id, + 'minutes_checked' => $recentMinutes, + ]); + + $subscription = Subscription::where('user_id', $user->id) + ->orderBy('created_at', 'desc') + ->first(); + } + + Log::info('PaymentSuccessController: Subscription selected for status checking', [ + 'user_id' => $user->id, + 'subscription_id' => $subscription?->id, + 'provider_subscription_id' => $subscription?->provider_subscription_id, + 'provider' => $subscription?->provider, + 'status' => $subscription?->status, + 'created_at' => $subscription?->created_at, + 'is_recent' => $subscription && $subscription->created_at->diffInMinutes(now()) <= $recentMinutes, + ]); + + return view('payment.success', [ + 'user' => $user, + 'subscription' => $subscription, + 'sessionToken' => $sessionToken, + ]); + } +} diff --git a/app/Livewire/PaymentConfirmation.php b/app/Livewire/PaymentConfirmation.php new file mode 100644 index 0000000..6aa35f2 --- /dev/null +++ b/app/Livewire/PaymentConfirmation.php @@ -0,0 +1,254 @@ +subscription = $subscription; + $this->sessionToken = $sessionToken; + + // Validate that we have a subscription to check + if (! $this->subscription) { + Log::warning('PaymentConfirmation: No subscription provided', [ + 'user_id' => auth()->id(), + 'session_token' => $sessionToken, + ]); + + $this->status = 'error'; + $this->errorMessage = 'No subscription found for this payment session.'; + + return; + } + + // Validate subscription belongs to current user + if ($this->subscription->user_id !== auth()->id()) { + Log::warning('PaymentConfirmation: Subscription does not belong to current user', [ + 'user_id' => auth()->id(), + 'subscription_user_id' => $this->subscription->user_id, + 'subscription_id' => $this->subscription->id, + ]); + + $this->status = 'error'; + $this->errorMessage = 'Invalid subscription for this user.'; + + return; + } + + Log::info('PaymentConfirmation: Mounted with subscription', [ + 'user_id' => auth()->id(), + 'subscription_id' => $this->subscription->id, + 'provider' => $this->subscription->provider, + 'provider_subscription_id' => $this->subscription->provider_subscription_id, + 'current_status' => $this->subscription->status, + 'created_at' => $this->subscription->created_at, + 'minutes_ago' => $this->subscription->created_at->diffInMinutes(now()), + ]); + + // Initial status check + $this->checkSubscriptionStatus(); + + // Debug: If subscription is already active, show confetti immediately + if ($this->subscription && $this->subscription->status === 'active') { + $this->status = 'activated'; + $this->showConfetti = true; + $this->pollCount = $this->maxPolls; + + Log::info('PaymentConfirmation: Active subscription detected, showing confetti immediately', [ + 'subscription_id' => $this->subscription->id, + 'status' => $this->subscription->status, + ]); + } + } + + /** + * Check subscription status via payment provider + */ + public function checkSubscriptionStatus(): void + { + if (! $this->subscription || $this->pollCount >= $this->maxPolls) { + if ($this->pollCount >= $this->maxPolls) { + Log::info('PaymentConfirmation: Max polls reached, redirecting to dashboard', [ + 'subscription_id' => $this->subscription?->id, + 'poll_count' => $this->pollCount, + ]); + + $this->redirect(route('dashboard')); + } + + return; + } + + // Increment poll count first + $this->pollCount++; + + try { + $orchestrator = app(PaymentOrchestrator::class); + $user = auth()->user(); + + Log::info('PaymentConfirmation: Checking subscription status', [ + 'subscription_id' => $this->subscription->id, + 'provider_subscription_id' => $this->subscription->provider_subscription_id, + 'provider' => $this->subscription->provider, + 'poll_count' => $this->pollCount, + ]); + + // Check status via provider + $statusResult = $orchestrator->checkSubscriptionStatus( + $user, + $this->subscription->provider, + $this->subscription->provider_subscription_id + ); + + if ($statusResult['success']) { + $providerStatus = $statusResult['status']; + + Log::info('PaymentConfirmation: Provider status received', [ + 'provider_status' => $providerStatus, + 'subscription_id' => $this->subscription->id, + ]); + + // Update local subscription if status changed + if ($providerStatus !== $this->subscription->status) { + $this->subscription->status = $providerStatus; + $this->subscription->save(); + + Log::info('PaymentConfirmation: Updated local subscription status', [ + 'old_status' => $this->subscription->getOriginal('status'), + 'new_status' => $providerStatus, + ]); + } + + // Check if subscription is now active + if ($providerStatus === 'active') { + $this->status = 'activated'; + $this->showConfetti = true; + + // Stop polling when activated + $this->pollCount = $this->maxPolls; + + Log::info('PaymentConfirmation: Subscription activated successfully', [ + 'subscription_id' => $this->subscription->id, + ]); + + return; + } + } + + // Continue polling if not active and max polls not reached + if ($this->pollCount < $this->maxPolls) { + $this->status = 'verifying'; + } else { + // Max polls reached, check final status + $this->status = in_array($this->subscription->status, ['active', 'trialing']) + ? 'activated' + : 'pending'; + + Log::info('PaymentConfirmation: Max polls reached, final status determined', [ + 'final_status' => $this->status, + 'subscription_status' => $this->subscription->status, + ]); + } + + } catch (\Exception $e) { + Log::error('PaymentConfirmation: Error checking subscription status', [ + 'subscription_id' => $this->subscription->id, + 'error' => $e->getMessage(), + 'poll_count' => $this->pollCount, + ]); + + // Don't immediately set error status, continue trying unless max polls reached + if ($this->pollCount >= $this->maxPolls) { + $this->errorMessage = 'Unable to verify payment status after multiple attempts. Please check your subscription page.'; + $this->status = 'error'; + } + } + } + + /** + * Get polling interval in milliseconds + */ + public function getPollingIntervalProperty(): int + { + return 30000; // 30 seconds + } + + /** + * Check if polling should continue + */ + public function getShouldContinuePollingProperty(): bool + { + return $this->status === 'verifying' && $this->pollCount < $this->maxPolls; + } + + /** + * Get status display text + */ + public function getStatusTextProperty(): string + { + return match ($this->status) { + 'verifying' => 'Verifying your payment...', + 'activated' => 'Payment successful! Your subscription is now active.', + 'pending' => 'Payment is being processed. Please check your subscription page.', + 'error' => 'Unable to verify payment status.', + default => 'Checking payment status...', + }; + } + + /** + * Get status icon + */ + public function getStatusIconProperty(): string + { + return match ($this->status) { + 'verifying' => 'clock', + 'activated' => 'check-circle', + 'pending' => 'clock', + 'error' => 'exclamation-triangle', + default => 'clock', + }; + } + + /** + * Get status color + */ + public function getStatusColorProperty(): string + { + return match ($this->status) { + 'verifying' => 'blue', + 'activated' => 'green', + 'pending' => 'yellow', + 'error' => 'red', + default => 'gray', + }; + } + + public function render(): Factory|View + { + return view('livewire.payment-confirmation'); + } +} diff --git a/app/Services/Payments/PaymentOrchestrator.php b/app/Services/Payments/PaymentOrchestrator.php index dc96ab2..1de5b95 100644 --- a/app/Services/Payments/PaymentOrchestrator.php +++ b/app/Services/Payments/PaymentOrchestrator.php @@ -938,4 +938,63 @@ class PaymentOrchestrator 'avg_extension_days' => $totalExtensions > 0 ? $totalDaysExtended / $totalExtensions : 0, ]; } + + /** + * Check subscription status via provider + */ + public function checkSubscriptionStatus(User $user, string $providerName, string $providerSubscriptionId): array + { + try { + $provider = $this->providerRegistry->get($providerName); + + if (! $provider) { + return [ + 'success' => false, + 'error' => "Provider {$providerName} not found", + ]; + } + + if (! $provider->isActive()) { + return [ + 'success' => false, + 'error' => "Provider {$providerName} is not active", + ]; + } + + Log::info('PaymentOrchestrator: Checking subscription status', [ + 'user_id' => $user->id, + 'provider' => $providerName, + 'provider_subscription_id' => $providerSubscriptionId, + ]); + + // Get subscription details from provider + $details = $provider->getSubscriptionDetails($providerSubscriptionId); + + if (empty($details)) { + return [ + 'success' => false, + 'error' => 'Unable to fetch subscription details from provider', + ]; + } + + return [ + 'success' => true, + 'status' => $details['status'] ?? 'unknown', + 'details' => $details, + ]; + + } catch (Exception $e) { + Log::error('PaymentOrchestrator: Error checking subscription status', [ + 'user_id' => $user->id, + 'provider' => $providerName, + 'provider_subscription_id' => $providerSubscriptionId, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'error' => $e->getMessage(), + ]; + } + } } diff --git a/app/Services/Payments/Providers/PolarProvider.php b/app/Services/Payments/Providers/PolarProvider.php index 2b8776e..6670907 100644 --- a/app/Services/Payments/Providers/PolarProvider.php +++ b/app/Services/Payments/Providers/PolarProvider.php @@ -461,10 +461,19 @@ class PolarProvider implements PaymentProviderContract if (! $response->successful()) { Log::error('Failed to retrieve Polar subscription: '.$response->body()); + throw new \Exception('Polar subscription not found: '.$response->status()); } $polarSubscription = $response->json(); + if (! $polarSubscription || ! isset($polarSubscription['id'])) { + Log::error('Invalid Polar subscription response', [ + 'subscription_id' => $providerSubscriptionId, + 'response' => $polarSubscription, + ]); + throw new \Exception('Invalid Polar subscription response'); + } + return [ 'id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], @@ -476,7 +485,7 @@ class PolarProvider implements PaymentProviderContract 'trial_start' => $polarSubscription['trial_start'] ?? null, 'trial_end' => $polarSubscription['trial_end'] ?? null, 'created_at' => $polarSubscription['created_at'], - 'updated_at' => $polarSubscription['updated_at'], + 'updated_at' => $polarSubscription['modified_at'] ?? null, ]; } catch (\Exception $e) { diff --git a/resources/views/livewire/payment-confirmation.blade.php b/resources/views/livewire/payment-confirmation.blade.php new file mode 100644 index 0000000..44adb5e --- /dev/null +++ b/resources/views/livewire/payment-confirmation.blade.php @@ -0,0 +1,259 @@ +
+ +
+ + + @if ($status === 'verifying') +
+
+ + + +
+ +

+ {{ $this->statusText }} +

+ +

+ We're confirming your payment with the provider. This usually takes a few seconds. +

+ + +
+
+
+
+
+ +

+ Check {{ $pollCount }} of {{ $maxPolls }} • Retrying in 30 seconds... +

+
+ @endif + + + @if ($status === 'activated') +
+
+ + + +
+ +

+ {{ $this->statusText }} +

+ +

+ Your subscription is now active and you have access to all premium features! +

+ + + +
+ @endif + + + @if ($status === 'pending') +
+
+ + + +
+ +

+ {{ $this->statusText }} +

+ +

+ Your payment is being processed. You'll receive an email once your subscription is active. +

+ +
+
+ + + +

+ This can take a few minutes. You can check your subscription status from the dashboard. +

+
+
+ + + +
+ @endif + + + @if ($status === 'error') +
+
+ + + +
+ +

+ {{ $this->statusText }} +

+ + @if ($errorMessage) +

{{ $errorMessage }}

+ @endif + +
+
+ + + +

+ If you continue to see this message, please contact our support team. +

+
+
+ + +
+ + + + + + + Go to Dashboard + +
+
+ @endif + + + @if ($subscription) +
+

Subscription Details

+ +
+
+

Subscription ID

+

{{ $subscription->provider_subscription_id }}

+
+ +
+

Provider

+

{{ ucfirst($subscription->provider) }}

+
+ +
+

Status

+
+ + {{ ucfirst($subscription->status) }} + +
+
+ +
+

Created

+

{{ $subscription->created_at->format('M j, Y H:i') }}

+
+
+
+ @endif + + + @if ($this->shouldContinuePolling) + + @endif +
+ + + \ No newline at end of file diff --git a/resources/views/payment/success.blade.php b/resources/views/payment/success.blade.php new file mode 100644 index 0000000..c838875 --- /dev/null +++ b/resources/views/payment/success.blade.php @@ -0,0 +1,131 @@ + + + + + + Payment Confirmation - {{ config('app.name') }} + + + + + + +
+
+
+
+
+
+ Z +
+

+ Zemailnator +

+
+
+ + + + + Back to Dashboard + +
+
+
+ + +
+
+ +
+ +
+
+ + + +
+

+ Payment Received +

+

+ Thank you for your subscription! We're confirming your payment status. +

+
+ + +
+ @livewire('payment-confirmation', [ + 'subscription' => $subscription, + 'sessionToken' => $sessionToken + ]) +
+
+ + +
+
+ + + + + Session: {{ $sessionToken ? substr($sessionToken, 0, 20) . '...' : 'Not provided' }} + +
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/routes/payment.php b/routes/payment.php index b9f5f70..e9c95f8 100644 --- a/routes/payment.php +++ b/routes/payment.php @@ -2,6 +2,7 @@ use App\Http\Controllers\PaymentController; use App\Http\Controllers\PaymentProviderController; +use App\Http\Controllers\PaymentSuccessController; use App\Http\Controllers\WebhookController; use Illuminate\Support\Facades\Route; @@ -12,7 +13,9 @@ use Illuminate\Support\Facades\Route; */ Route::prefix('payment')->name('payment.')->group(function () { - Route::get('/success', [PaymentController::class, 'success'])->name('success'); + Route::get('/success', [PaymentSuccessController::class, 'show']) + ->middleware(['auth', 'verified']) + ->name('success'); Route::get('/cancel', [PaymentController::class, 'cancel'])->name('cancel'); // UNIFIED: Payment processing endpoints (new unified payment system)