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

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