feat(payment): implement comprehensive Polar subscription sync with proper date and cancellation handling

- Add Polar-specific date field mapping in PaymentOrchestrator (current_period_start, current_period_end, cancelled_at, trial_end)
  - Handle both cancellation scenarios: cancel_at_period_end=true and existing cancelled_at timestamp
  - Map customer_cancellation_reason and customer_cancellation_comment from Polar to database
  - Update billing page to show correct renewal vs expiry dates based on cancellation status
  - Restrict cancel button to activation_key provider only (Polar uses customer portal)
  - Fix button spacing between "Manage in Polar" and "Sync" buttons
  - Ensure both "Sync" and "Recheck Status" buttons use identical sync functionality
This commit is contained in:
idevakk
2025-12-06 10:42:25 -08:00
parent 0724e6da43
commit 15e018eb88
4 changed files with 150 additions and 18 deletions

View File

@@ -197,7 +197,7 @@ class Subscription extends Model
{
try {
// For Polar provider, check if we need to fetch subscription ID first
if ($this->provider === 'polar' && empty($this->provider_subscription_id) && !empty($this->user->polar_cust_id)) {
if ($this->provider === 'polar' && empty($this->provider_subscription_id) && ! empty($this->user->polar_cust_id)) {
$this->fetchPolarSubscriptionId();
}
@@ -252,7 +252,7 @@ class Subscription extends Model
$data = $response->json();
$subscriptions = $data['items'] ?? [];
if (!empty($subscriptions)) {
if (! empty($subscriptions)) {
// Find the subscription that matches our plan or take the most recent active one
$matchingSubscription = null;
@@ -265,23 +265,55 @@ class Subscription extends Model
}
// If no exact match, take the most recent active subscription
if (!$matchingSubscription && !empty($subscriptions)) {
if (! $matchingSubscription && ! empty($subscriptions)) {
$matchingSubscription = $subscriptions[0];
}
if ($matchingSubscription) {
// Parse dates from Polar response
$startsAt = null;
$endsAt = null;
$cancelledAt = null;
// Handle current_period_start
if (isset($matchingSubscription['current_period_start'])) {
$startsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_start']);
}
// Handle current_period_end (renewal date)
if (isset($matchingSubscription['current_period_end'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['current_period_end']);
}
// Handle ends_at (cancellation/expiry date)
elseif (isset($matchingSubscription['ends_at'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['ends_at']);
}
// Handle expires_at (expiry date)
elseif (isset($matchingSubscription['expires_at'])) {
$endsAt = \Carbon\Carbon::parse($matchingSubscription['expires_at']);
}
// Handle cancelled_at
if (isset($matchingSubscription['cancelled_at'])) {
$cancelledAt = \Carbon\Carbon::parse($matchingSubscription['cancelled_at']);
}
$this->update([
'provider_subscription_id' => $matchingSubscription['id'],
'status' => $matchingSubscription['status'],
'starts_at' => isset($matchingSubscription['current_period_start'])
? \Carbon\Carbon::parse($matchingSubscription['current_period_start'])
: null,
'ends_at' => isset($matchingSubscription['current_period_end'])
? \Carbon\Carbon::parse($matchingSubscription['current_period_end'])
: null,
'starts_at' => $startsAt,
'ends_at' => $endsAt,
'cancelled_at' => $cancelledAt,
'provider_data' => array_merge($this->provider_data ?? [], [
'polar_subscription' => $matchingSubscription,
'subscription_id_fetched_at' => now()->toISOString(),
'polar_dates' => [
'current_period_start' => $matchingSubscription['current_period_start'] ?? null,
'current_period_end' => $matchingSubscription['current_period_end'] ?? null,
'ends_at' => $matchingSubscription['ends_at'] ?? null,
'expires_at' => $matchingSubscription['expires_at'] ?? null,
'cancelled_at' => $matchingSubscription['cancelled_at'] ?? null,
],
]),
]);
@@ -289,6 +321,9 @@ class Subscription extends Model
'subscription_id' => $this->id,
'polar_subscription_id' => $matchingSubscription['id'],
'customer_id' => $this->user->polar_cust_id,
'starts_at' => $startsAt?->toISOString(),
'ends_at' => $endsAt?->toISOString(),
'cancelled_at' => $cancelledAt?->toISOString(),
]);
}
}

View File

@@ -698,15 +698,96 @@ class PaymentOrchestrator
*/
protected function updateLocalSubscriptionFromProvider(Subscription $subscription, array $providerData): void
{
$subscription->update([
$updateData = [
'status' => $providerData['status'] ?? $subscription->status,
'ends_at' => $providerData['ends_at'] ?? $subscription->ends_at,
'trial_ends_at' => $providerData['trial_ends_at'] ?? $subscription->trial_ends_at,
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
'synced_at' => now(),
];
// Handle Polar-specific date mapping
if ($subscription->provider === 'polar') {
// Map Polar's date fields to our database columns
$updateData['starts_at'] = $this->parseDateTime($providerData['current_period_start'] ?? null);
// Check if subscription is scheduled for cancellation
$isScheduledForCancellation = $providerData['cancel_at_period_end'] ?? false;
// For ends_at and cancelled_at logic:
if (! empty($providerData['cancelled_at'])) {
// Already cancelled subscription - use Polar's actual cancellation data
$updateData['ends_at'] = $this->parseDateTime($providerData['ends_at'] ?? $providerData['current_period_end'] ?? null);
$updateData['cancelled_at'] = $this->parseDateTime($providerData['cancelled_at']);
$updateData['status'] = 'cancelled';
} elseif ($isScheduledForCancellation) {
// Scheduled for cancellation - treat as cancelled with expiry at period end
$updateData['ends_at'] = $this->parseDateTime($providerData['current_period_end'] ?? null);
$updateData['cancelled_at'] = now(); // Set cancellation time to now when detected
$updateData['status'] = 'cancelled';
$updateData['cancellation_reason'] = $updateData['cancellation_reason'] ?? 'Customer cancelled via Polar portal (cancel at period end)';
} else {
// Active subscription
$updateData['ends_at'] = $this->parseDateTime($providerData['current_period_end'] ?? null);
// Don't overwrite existing cancelled_at for active subscriptions
}
$updateData['trial_ends_at'] = $this->parseDateTime($providerData['trial_end'] ?? null);
// Map cancellation reason if available
if (! empty($providerData['customer_cancellation_reason'])) {
$updateData['cancellation_reason'] = $providerData['customer_cancellation_reason'];
// Also store the comment if available
if (! empty($providerData['customer_cancellation_comment'])) {
$updateData['cancellation_reason'] .= ' - Comment: '.$providerData['customer_cancellation_comment'];
}
}
} else {
// Generic date mapping for other providers
$updateData['ends_at'] = $this->parseDateTime($providerData['ends_at'] ?? $subscription->ends_at);
$updateData['trial_ends_at'] = $this->parseDateTime($providerData['trial_ends_at'] ?? $subscription->trial_ends_at);
}
// Only update fields that are actually provided (not null)
$updateData = array_filter($updateData, function ($value, $key) use ($providerData) {
// Keep null values from API if they're explicitly provided
if (array_key_exists($key, $providerData)) {
return true;
}
// Otherwise don't overwrite existing values with null
return $value !== null;
}, ARRAY_FILTER_USE_BOTH);
$subscription->update($updateData);
Log::info('Subscription updated from provider data', [
'subscription_id' => $subscription->id,
'provider' => $subscription->provider,
'update_data' => $updateData,
]);
}
/**
* Parse datetime from various formats
*/
protected function parseDateTime($dateTime): ?\Carbon\Carbon
{
if (empty($dateTime)) {
return null;
}
try {
return \Carbon\Carbon::parse($dateTime);
} catch (\Exception $e) {
Log::warning('Failed to parse datetime', [
'datetime' => $dateTime,
'error' => $e->getMessage(),
]);
return null;
}
}
/**
* Get all available providers
*/

View File

@@ -482,6 +482,13 @@ class PolarProvider implements PaymentProviderContract
$polarSubscription = $response->json();
// Log the full Polar subscription response for debugging
Log::info('Polar subscription response received', [
'subscription_id' => $providerSubscriptionId,
'response_keys' => array_keys($polarSubscription),
'full_response' => $polarSubscription,
]);
if (! $polarSubscription || ! isset($polarSubscription['id'])) {
Log::error('Invalid Polar subscription response', [
'subscription_id' => $providerSubscriptionId,
@@ -495,13 +502,18 @@ class PolarProvider implements PaymentProviderContract
'status' => $polarSubscription['status'],
'customer_id' => $polarSubscription['customer_id'],
'price_id' => $polarSubscription['price_id'],
'current_period_start' => $polarSubscription['current_period_start'],
'current_period_end' => $polarSubscription['current_period_end'],
'current_period_start' => $polarSubscription['current_period_start'] ?? null,
'current_period_end' => $polarSubscription['current_period_end'] ?? null,
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
'trial_start' => $polarSubscription['trial_start'] ?? null,
'trial_end' => $polarSubscription['trial_end'] ?? null,
'created_at' => $polarSubscription['created_at'],
'created_at' => $polarSubscription['created_at'] ?? null,
'updated_at' => $polarSubscription['modified_at'] ?? null,
'ends_at' => $polarSubscription['ends_at'] ?? null, // Check if Polar has ends_at
'expires_at' => $polarSubscription['expires_at'] ?? null, // Check if Polar has expires_at
'cancelled_at' => $polarSubscription['cancelled_at'] ?? null, // Check if Polar has cancelled_at
'customer_cancellation_reason' => $polarSubscription['customer_cancellation_reason'] ?? null,
'customer_cancellation_comment' => $polarSubscription['customer_cancellation_comment'] ?? null,
];
} catch (\Exception $e) {

View File

@@ -68,7 +68,11 @@
@if($latestActiveSubscription->isActive())
<flux:text class="mt-1 text-sm text-gray-500 dark:text-gray-500">
@if($latestActiveSubscription->ends_at)
@if($latestActiveSubscription->cancelled_at)
{{ __('Expires on :date', ['date' => $latestActiveSubscription->ends_at->format('M j, Y')]) }}
@else
{{ __('Renews on :date', ['date' => $latestActiveSubscription->ends_at->format('M j, Y')]) }}
@endif
@else
{{ __('Active subscription') }}
@endif
@@ -93,7 +97,7 @@
wire:click="managePolarSubscription"
variant="outline"
size="sm"
class="w-full sm:w-auto cursor-pointer"
class="w-full sm:w-auto cursor-pointer sm:mr-2"
>
{{ __('Manage in Polar') }}
</flux:button>
@@ -112,7 +116,7 @@
</flux:button>
@endif
@if($latestActiveSubscription->isActive() && $latestActiveSubscription->provider !== 'activation_key')
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key']))
<flux:button
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
variant="danger"