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:
@@ -117,7 +117,19 @@ class PaymentConfirmation extends Component
|
|||||||
'poll_count' => $this->pollCount,
|
'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(
|
$statusResult = $orchestrator->checkSubscriptionStatus(
|
||||||
$user,
|
$user,
|
||||||
$this->subscription->provider,
|
$this->subscription->provider,
|
||||||
@@ -157,6 +169,19 @@ class PaymentConfirmation extends Component
|
|||||||
|
|
||||||
return;
|
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
|
// Continue polling if not active and max polls not reached
|
||||||
|
|||||||
@@ -1023,7 +1023,7 @@ class PaymentOrchestrator
|
|||||||
/**
|
/**
|
||||||
* Check subscription status via provider
|
* 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 {
|
try {
|
||||||
$provider = $this->providerRegistry->get($providerName);
|
$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', [
|
Log::info('PaymentOrchestrator: Checking subscription status', [
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'provider' => $providerName,
|
'provider' => $providerName,
|
||||||
|
|||||||
@@ -648,6 +648,7 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
$result = $this->handleSubscriptionUpdated($webhookData);
|
$result = $this->handleSubscriptionUpdated($webhookData);
|
||||||
break;
|
break;
|
||||||
case 'subscription.cancelled':
|
case 'subscription.cancelled':
|
||||||
|
case 'subscription.canceled': // Handle both spellings
|
||||||
$result = $this->handleSubscriptionCancelled($webhookData);
|
$result = $this->handleSubscriptionCancelled($webhookData);
|
||||||
break;
|
break;
|
||||||
case 'subscription.paused':
|
case 'subscription.paused':
|
||||||
@@ -662,6 +663,9 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
case 'subscription.trial_ended':
|
case 'subscription.trial_ended':
|
||||||
$result = $this->handleSubscriptionTrialEnded($webhookData);
|
$result = $this->handleSubscriptionTrialEnded($webhookData);
|
||||||
break;
|
break;
|
||||||
|
case 'subscription.uncanceled':
|
||||||
|
$result = $this->handleSubscriptionUncanceled($webhookData);
|
||||||
|
break;
|
||||||
case 'customer.state_changed':
|
case 'customer.state_changed':
|
||||||
$result = $this->handleCustomerStateChanged($webhookData);
|
$result = $this->handleCustomerStateChanged($webhookData);
|
||||||
break;
|
break;
|
||||||
@@ -1394,6 +1398,7 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
||||||
'polar_subscription' => $polarSubscription,
|
'polar_subscription' => $polarSubscription,
|
||||||
'updated_at' => now()->toISOString(),
|
'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'])) {
|
if (! empty($polarSubscription['cancelled_at'])) {
|
||||||
$updateData['cancelled_at'] = Carbon::parse($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'])) {
|
if (! empty($polarSubscription['ends_at'])) {
|
||||||
$updateData['ends_at'] = Carbon::parse($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);
|
$localSubscription->update($updateData);
|
||||||
|
|
||||||
Log::info('Polar subscription updated via webhook', [
|
Log::info('Polar subscription updated via webhook', [
|
||||||
'local_subscription_id' => $localSubscription->id,
|
'local_subscription_id' => $localSubscription->id,
|
||||||
'polar_subscription_id' => $polarSubscription['id'],
|
'polar_subscription_id' => $polarSubscription['id'],
|
||||||
'status' => $polarSubscription['status'],
|
'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 {
|
} else {
|
||||||
Log::warning('Subscription not found for Polar subscription.updated webhook', [
|
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)';
|
$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 = [
|
$updateData = [
|
||||||
'status' => 'cancelled',
|
'status' => $shouldRemainActive ? $polarSubscription['status'] : 'cancelled',
|
||||||
'cancellation_reason' => $cancellationReason,
|
'cancellation_reason' => $cancellationReason,
|
||||||
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
'provider_data' => array_merge($localSubscription->provider_data ?? [], [
|
||||||
'polar_subscription' => $polarSubscription,
|
'polar_subscription' => $polarSubscription,
|
||||||
'cancelled_at_webhook' => now()->toISOString(),
|
'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
|
// Use Polar's cancellation timestamp if available, otherwise use now
|
||||||
if (! empty($polarSubscription['cancelled_at'])) {
|
if (! empty($polarSubscription['cancelled_at'])) {
|
||||||
$updateData['cancelled_at'] = Carbon::parse($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 {
|
} else {
|
||||||
$updateData['cancelled_at'] = now();
|
$updateData['cancelled_at'] = now();
|
||||||
}
|
}
|
||||||
@@ -1490,9 +1524,12 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
|
|
||||||
$localSubscription->update($updateData);
|
$localSubscription->update($updateData);
|
||||||
|
|
||||||
Log::info('Polar subscription cancelled via webhook', [
|
Log::info('Polar subscription cancellation processed via webhook', [
|
||||||
'local_subscription_id' => $localSubscription->id,
|
'local_subscription_id' => $localSubscription->id,
|
||||||
'polar_subscription_id' => $polarSubscription['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,
|
'cancellation_reason' => $cancellationReason,
|
||||||
'cancelled_at' => $updateData['cancelled_at']->toISOString(),
|
'cancelled_at' => $updateData['cancelled_at']->toISOString(),
|
||||||
'ends_at' => $updateData['ends_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
|
protected function handleCustomerStateChanged(array $webhookData): array
|
||||||
{
|
{
|
||||||
$customer = $webhookData['data'];
|
$customer = $webhookData['data'];
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ Polar webhook endpoints are automatically configured based on your payment provi
|
|||||||
- `subscription.resumed`
|
- `subscription.resumed`
|
||||||
- `subscription.trial_will_end`
|
- `subscription.trial_will_end`
|
||||||
- `subscription.trial_ended`
|
- `subscription.trial_ended`
|
||||||
|
- `subscription.uncanceled`
|
||||||
- `customer.state_changed`
|
- `customer.state_changed`
|
||||||
|
|
||||||
## Security Features
|
## Security Features
|
||||||
@@ -155,6 +156,11 @@ Webhook processing is idempotent to prevent duplicate processing:
|
|||||||
- **Action**: Converts trial to active subscription
|
- **Action**: Converts trial to active subscription
|
||||||
- **Data**: Trial end date, new billing period
|
- **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 Events
|
||||||
|
|
||||||
#### `customer.state_changed`
|
#### `customer.state_changed`
|
||||||
|
|||||||
Reference in New Issue
Block a user