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.
This commit is contained in:
idevakk
2025-12-07 00:57:46 -08:00
parent 289baa1286
commit 1b438cbf89
4 changed files with 182 additions and 11 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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'];

View File

@@ -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`