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:
@@ -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'];
|
||||
|
||||
Reference in New Issue
Block a user