From 1b438cbf894803b564e54d09499e4c1fcf70d601 Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Sun, 7 Dec 2025 00:57:46 -0800 Subject: [PATCH] feat(webhooks): enhance Polar webhook processing with proper event handling - Add support for subscription.uncanceled webhook event - Fix spelling mismatch for subscription.canceled (Polar) vs subscription.cancelled (code) - Implement proper cancel_at_period_end handling in subscription.canceled events - Add cancelled_at field updates for subscription.updated events - Handle Polar's spelling variants (canceled_at vs cancelled_at) consistently - Remove non-existent pause_reason column from subscription uncanceled handler - Enhance webhook logging with detailed field update tracking - Add comprehensive cancellation metadata storage in provider_data - Gracefully handle null provider_subscription_id in payment confirmation polling All Polar webhook events now properly sync subscription state including cancellation timing, reasons, and billing period details. --- app/Livewire/PaymentConfirmation.php | 27 +++- app/Services/Payments/PaymentOrchestrator.php | 15 +- .../Payments/Providers/PolarProvider.php | 145 ++++++++++++++++-- docs/polar-webhooks.md | 6 + 4 files changed, 182 insertions(+), 11 deletions(-) diff --git a/app/Livewire/PaymentConfirmation.php b/app/Livewire/PaymentConfirmation.php index 6aa35f2..3765388 100644 --- a/app/Livewire/PaymentConfirmation.php +++ b/app/Livewire/PaymentConfirmation.php @@ -117,7 +117,19 @@ class PaymentConfirmation extends Component 'poll_count' => $this->pollCount, ]); - // Check status via provider + // Check status via provider (only if we have a provider subscription ID) + if (empty($this->subscription->provider_subscription_id)) { + Log::info('PaymentConfirmation: Skipping provider status check - no provider subscription ID yet', [ + 'subscription_id' => $this->subscription->id, + 'provider' => $this->subscription->provider, + 'provider_checkout_id' => $this->subscription->provider_checkout_id, + 'poll_count' => $this->pollCount, + ]); + + // Don't update status, just continue polling + return; + } + $statusResult = $orchestrator->checkSubscriptionStatus( $user, $this->subscription->provider, @@ -157,6 +169,19 @@ class PaymentConfirmation extends Component return; } + } else { + // Handle status check failure + Log::warning('PaymentConfirmation: Provider status check failed', [ + 'error' => $statusResult['error'] ?? 'Unknown error', + 'subscription_id' => $this->subscription->id, + 'retry_suggested' => $statusResult['retry_suggested'] ?? false, + ]); + + // If retry is suggested (e.g., webhook not processed yet), continue polling + if (! ($statusResult['retry_suggested'] ?? false)) { + // If retry is not suggested, we might want to show an error + // but for now, continue polling to be safe + } } // Continue polling if not active and max polls not reached diff --git a/app/Services/Payments/PaymentOrchestrator.php b/app/Services/Payments/PaymentOrchestrator.php index 997114c..03eb25d 100644 --- a/app/Services/Payments/PaymentOrchestrator.php +++ b/app/Services/Payments/PaymentOrchestrator.php @@ -1023,7 +1023,7 @@ class PaymentOrchestrator /** * Check subscription status via provider */ - public function checkSubscriptionStatus(User $user, string $providerName, string $providerSubscriptionId): array + public function checkSubscriptionStatus(User $user, string $providerName, ?string $providerSubscriptionId): array { try { $provider = $this->providerRegistry->get($providerName); @@ -1042,6 +1042,19 @@ class PaymentOrchestrator ]; } + if (empty($providerSubscriptionId)) { + Log::info('PaymentOrchestrator: Cannot check status - no provider subscription ID', [ + 'user_id' => $user->id, + 'provider' => $providerName, + ]); + + return [ + 'success' => false, + 'error' => 'Provider subscription ID not available - webhook may not have processed yet', + 'retry_suggested' => true, + ]; + } + Log::info('PaymentOrchestrator: Checking subscription status', [ 'user_id' => $user->id, 'provider' => $providerName, diff --git a/app/Services/Payments/Providers/PolarProvider.php b/app/Services/Payments/Providers/PolarProvider.php index 4256d5a..294a838 100644 --- a/app/Services/Payments/Providers/PolarProvider.php +++ b/app/Services/Payments/Providers/PolarProvider.php @@ -648,6 +648,7 @@ class PolarProvider implements PaymentProviderContract $result = $this->handleSubscriptionUpdated($webhookData); break; case 'subscription.cancelled': + case 'subscription.canceled': // Handle both spellings $result = $this->handleSubscriptionCancelled($webhookData); break; case 'subscription.paused': @@ -662,6 +663,9 @@ class PolarProvider implements PaymentProviderContract case 'subscription.trial_ended': $result = $this->handleSubscriptionTrialEnded($webhookData); break; + case 'subscription.uncanceled': + $result = $this->handleSubscriptionUncanceled($webhookData); + break; case 'customer.state_changed': $result = $this->handleCustomerStateChanged($webhookData); break; @@ -691,13 +695,13 @@ class PolarProvider implements PaymentProviderContract { try { // In sandbox mode, bypass validation for development -// if ($this->sandbox) { -// Log::info('Polar webhook validation bypassed in sandbox mode', [ -// 'sandbox_bypass' => true, -// ]); -// -// return true; -// } + // if ($this->sandbox) { + // Log::info('Polar webhook validation bypassed in sandbox mode', [ + // 'sandbox_bypass' => true, + // ]); + // + // return true; + // } // Check if we have a webhook secret if (empty($this->webhookSecret)) { @@ -1394,6 +1398,7 @@ class PolarProvider implements PaymentProviderContract 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'updated_at' => now()->toISOString(), + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, ]), ]; @@ -1409,17 +1414,34 @@ class PolarProvider implements PaymentProviderContract } if (! empty($polarSubscription['cancelled_at'])) { $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); + } elseif (! empty($polarSubscription['canceled_at'])) { + // Handle Polar's spelling with 1 'L' + $updateData['cancelled_at'] = Carbon::parse($polarSubscription['canceled_at']); } if (! empty($polarSubscription['ends_at'])) { $updateData['ends_at'] = Carbon::parse($polarSubscription['ends_at']); } + // Handle cancellation details + if (isset($polarSubscription['cancel_at_period_end'])) { + $updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end']; + } + + // Set cancellation reason if provided + if (! empty($polarSubscription['customer_cancellation_reason'])) { + $updateData['cancellation_reason'] = $polarSubscription['customer_cancellation_reason']; + } + $localSubscription->update($updateData); Log::info('Polar subscription updated via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], 'status' => $polarSubscription['status'], + 'updated_fields' => array_keys($updateData), + 'cancelled_at_updated' => isset($updateData['cancelled_at']), + 'cancellation_reason_updated' => isset($updateData['cancellation_reason']), + 'cancel_at_period_end_updated' => isset($updateData['cancel_at_period_end']), ]); } else { Log::warning('Subscription not found for Polar subscription.updated webhook', [ @@ -1464,18 +1486,30 @@ class PolarProvider implements PaymentProviderContract $cancellationReason = 'Customer cancelled via Polar portal (cancel at period end)'; } + // Check if subscription should remain active until period end + $shouldRemainActive = ! empty($polarSubscription['cancel_at_period_end']) && $polarSubscription['status'] === 'active'; + $updateData = [ - 'status' => 'cancelled', + 'status' => $shouldRemainActive ? $polarSubscription['status'] : 'cancelled', 'cancellation_reason' => $cancellationReason, 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ 'polar_subscription' => $polarSubscription, 'cancelled_at_webhook' => now()->toISOString(), + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, ]), ]; + // Set cancel_at_period_end flag if provided + if (isset($polarSubscription['cancel_at_period_end'])) { + $updateData['cancel_at_period_end'] = $polarSubscription['cancel_at_period_end']; + } + // Use Polar's cancellation timestamp if available, otherwise use now if (! empty($polarSubscription['cancelled_at'])) { $updateData['cancelled_at'] = Carbon::parse($polarSubscription['cancelled_at']); + } elseif (! empty($polarSubscription['canceled_at'])) { + // Handle Polar's spelling with 1 'L' + $updateData['cancelled_at'] = Carbon::parse($polarSubscription['canceled_at']); } else { $updateData['cancelled_at'] = now(); } @@ -1490,9 +1524,12 @@ class PolarProvider implements PaymentProviderContract $localSubscription->update($updateData); - Log::info('Polar subscription cancelled via webhook', [ + Log::info('Polar subscription cancellation processed via webhook', [ 'local_subscription_id' => $localSubscription->id, 'polar_subscription_id' => $polarSubscription['id'], + 'polar_status' => $polarSubscription['status'], + 'local_status_set' => $updateData['status'], + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, 'cancellation_reason' => $cancellationReason, 'cancelled_at' => $updateData['cancelled_at']->toISOString(), 'ends_at' => $updateData['ends_at']?->toISOString(), @@ -1776,6 +1813,96 @@ class PolarProvider implements PaymentProviderContract ]; } + protected function handleSubscriptionUncanceled(array $webhookData): array + { + $polarSubscription = $webhookData['data']; + + Log::info('Processing Polar subscription uncanceled webhook', [ + 'polar_subscription_id' => $polarSubscription['id'], + 'customer_id' => $polarSubscription['customer_id'], + 'status' => $polarSubscription['status'], + ]); + + // Find local subscription by provider subscription ID + $localSubscription = \App\Models\Subscription::where('provider', 'polar') + ->where('provider_subscription_id', $polarSubscription['id']) + ->first(); + + if (! $localSubscription) { + Log::warning('Polar subscription uncanceled: local subscription not found', [ + 'polar_subscription_id' => $polarSubscription['id'], + 'customer_id' => $polarSubscription['customer_id'], + ]); + + return [ + 'event_type' => 'subscription.uncanceled', + 'processed' => false, + 'error' => 'Local subscription not found', + 'data' => [ + 'polar_subscription_id' => $polarSubscription['id'], + ], + ]; + } + + // Parse dates from Polar response + $startsAt = null; + $endsAt = null; + $cancelledAt = null; + + if (isset($polarSubscription['current_period_start'])) { + $startsAt = \Carbon\Carbon::parse($polarSubscription['current_period_start']); + } + + if (isset($polarSubscription['current_period_end'])) { + $endsAt = \Carbon\Carbon::parse($polarSubscription['current_period_end']); + } + // Handle ends_at (cancellation/expiry date) + elseif (isset($polarSubscription['ends_at'])) { + $endsAt = \Carbon\Carbon::parse($polarSubscription['ends_at']); + } + + // Handle cancelled_at (should be null for uncanceled subscriptions) + if (isset($polarSubscription['canceled_at'])) { + $cancelledAt = \Carbon\Carbon::parse($polarSubscription['canceled_at']); + } + + // Update local subscription - subscription has been reactivated + $localSubscription->update([ + 'status' => $polarSubscription['status'] ?? 'active', // Should be active + 'cancelled_at' => $cancelledAt, // Should be null for uncanceled + 'cancellation_reason' => null, // Clear cancellation reason + 'ends_at' => $endsAt, // Should be null or updated to new end date + 'resumed_at' => now(), // Mark as resumed + 'paused_at' => null, // Clear pause date if any + 'starts_at' => $startsAt, + 'provider_data' => array_merge($localSubscription->provider_data ?? [], [ + 'polar_subscription' => $polarSubscription, + 'uncanceled_at' => now()->toISOString(), + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, + ]), + ]); + + Log::info('Polar subscription uncanceled successfully', [ + 'local_subscription_id' => $localSubscription->id, + 'polar_subscription_id' => $polarSubscription['id'], + 'uncanceled_at' => now()->toISOString(), + 'new_status' => $polarSubscription['status'] ?? 'active', + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, + ]); + + return [ + 'event_type' => 'subscription.uncanceled', + 'processed' => true, + 'data' => [ + 'subscription_id' => $polarSubscription['id'], + 'local_subscription_id' => $localSubscription->id, + 'uncanceled_at' => now()->toISOString(), + 'new_status' => $polarSubscription['status'] ?? 'active', + 'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false, + ], + ]; + } + protected function handleCustomerStateChanged(array $webhookData): array { $customer = $webhookData['data']; diff --git a/docs/polar-webhooks.md b/docs/polar-webhooks.md index 68bc5d9..e56e840 100644 --- a/docs/polar-webhooks.md +++ b/docs/polar-webhooks.md @@ -36,6 +36,7 @@ Polar webhook endpoints are automatically configured based on your payment provi - `subscription.resumed` - `subscription.trial_will_end` - `subscription.trial_ended` + - `subscription.uncanceled` - `customer.state_changed` ## Security Features @@ -155,6 +156,11 @@ Webhook processing is idempotent to prevent duplicate processing: - **Action**: Converts trial to active subscription - **Data**: Trial end date, new billing period +#### `subscription.uncanceled` +- **Trigger**: Previously cancelled subscription is reactivated before cancellation takes effect +- **Action**: Reactivates subscription and clears cancellation details +- **Data**: Subscription ID, new billing period dates, cancellation status cleared + ### Customer Events #### `customer.state_changed`