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:
@@ -197,7 +197,7 @@ class Subscription extends Model
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
// For Polar provider, check if we need to fetch subscription ID first
|
// 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();
|
$this->fetchPolarSubscriptionId();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,7 +252,7 @@ class Subscription extends Model
|
|||||||
$data = $response->json();
|
$data = $response->json();
|
||||||
$subscriptions = $data['items'] ?? [];
|
$subscriptions = $data['items'] ?? [];
|
||||||
|
|
||||||
if (!empty($subscriptions)) {
|
if (! empty($subscriptions)) {
|
||||||
// Find the subscription that matches our plan or take the most recent active one
|
// Find the subscription that matches our plan or take the most recent active one
|
||||||
$matchingSubscription = null;
|
$matchingSubscription = null;
|
||||||
|
|
||||||
@@ -265,23 +265,55 @@ class Subscription extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If no exact match, take the most recent active subscription
|
// If no exact match, take the most recent active subscription
|
||||||
if (!$matchingSubscription && !empty($subscriptions)) {
|
if (! $matchingSubscription && ! empty($subscriptions)) {
|
||||||
$matchingSubscription = $subscriptions[0];
|
$matchingSubscription = $subscriptions[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($matchingSubscription) {
|
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([
|
$this->update([
|
||||||
'provider_subscription_id' => $matchingSubscription['id'],
|
'provider_subscription_id' => $matchingSubscription['id'],
|
||||||
'status' => $matchingSubscription['status'],
|
'status' => $matchingSubscription['status'],
|
||||||
'starts_at' => isset($matchingSubscription['current_period_start'])
|
'starts_at' => $startsAt,
|
||||||
? \Carbon\Carbon::parse($matchingSubscription['current_period_start'])
|
'ends_at' => $endsAt,
|
||||||
: null,
|
'cancelled_at' => $cancelledAt,
|
||||||
'ends_at' => isset($matchingSubscription['current_period_end'])
|
|
||||||
? \Carbon\Carbon::parse($matchingSubscription['current_period_end'])
|
|
||||||
: null,
|
|
||||||
'provider_data' => array_merge($this->provider_data ?? [], [
|
'provider_data' => array_merge($this->provider_data ?? [], [
|
||||||
'polar_subscription' => $matchingSubscription,
|
'polar_subscription' => $matchingSubscription,
|
||||||
'subscription_id_fetched_at' => now()->toISOString(),
|
'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,
|
'subscription_id' => $this->id,
|
||||||
'polar_subscription_id' => $matchingSubscription['id'],
|
'polar_subscription_id' => $matchingSubscription['id'],
|
||||||
'customer_id' => $this->user->polar_cust_id,
|
'customer_id' => $this->user->polar_cust_id,
|
||||||
|
'starts_at' => $startsAt?->toISOString(),
|
||||||
|
'ends_at' => $endsAt?->toISOString(),
|
||||||
|
'cancelled_at' => $cancelledAt?->toISOString(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -698,15 +698,96 @@ class PaymentOrchestrator
|
|||||||
*/
|
*/
|
||||||
protected function updateLocalSubscriptionFromProvider(Subscription $subscription, array $providerData): void
|
protected function updateLocalSubscriptionFromProvider(Subscription $subscription, array $providerData): void
|
||||||
{
|
{
|
||||||
$subscription->update([
|
$updateData = [
|
||||||
'status' => $providerData['status'] ?? $subscription->status,
|
'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),
|
'provider_data' => array_merge($subscription->provider_data ?? [], $providerData),
|
||||||
'synced_at' => now(),
|
'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
|
* Get all available providers
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -482,6 +482,13 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
|
|
||||||
$polarSubscription = $response->json();
|
$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'])) {
|
if (! $polarSubscription || ! isset($polarSubscription['id'])) {
|
||||||
Log::error('Invalid Polar subscription response', [
|
Log::error('Invalid Polar subscription response', [
|
||||||
'subscription_id' => $providerSubscriptionId,
|
'subscription_id' => $providerSubscriptionId,
|
||||||
@@ -495,13 +502,18 @@ class PolarProvider implements PaymentProviderContract
|
|||||||
'status' => $polarSubscription['status'],
|
'status' => $polarSubscription['status'],
|
||||||
'customer_id' => $polarSubscription['customer_id'],
|
'customer_id' => $polarSubscription['customer_id'],
|
||||||
'price_id' => $polarSubscription['price_id'],
|
'price_id' => $polarSubscription['price_id'],
|
||||||
'current_period_start' => $polarSubscription['current_period_start'],
|
'current_period_start' => $polarSubscription['current_period_start'] ?? null,
|
||||||
'current_period_end' => $polarSubscription['current_period_end'],
|
'current_period_end' => $polarSubscription['current_period_end'] ?? null,
|
||||||
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
'cancel_at_period_end' => $polarSubscription['cancel_at_period_end'] ?? false,
|
||||||
'trial_start' => $polarSubscription['trial_start'] ?? null,
|
'trial_start' => $polarSubscription['trial_start'] ?? null,
|
||||||
'trial_end' => $polarSubscription['trial_end'] ?? null,
|
'trial_end' => $polarSubscription['trial_end'] ?? null,
|
||||||
'created_at' => $polarSubscription['created_at'],
|
'created_at' => $polarSubscription['created_at'] ?? null,
|
||||||
'updated_at' => $polarSubscription['modified_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) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -68,7 +68,11 @@
|
|||||||
@if($latestActiveSubscription->isActive())
|
@if($latestActiveSubscription->isActive())
|
||||||
<flux:text class="mt-1 text-sm text-gray-500 dark:text-gray-500">
|
<flux:text class="mt-1 text-sm text-gray-500 dark:text-gray-500">
|
||||||
@if($latestActiveSubscription->ends_at)
|
@if($latestActiveSubscription->ends_at)
|
||||||
{{ __('Renews on :date', ['date' => $latestActiveSubscription->ends_at->format('M j, Y')]) }}
|
@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
|
@else
|
||||||
{{ __('Active subscription') }}
|
{{ __('Active subscription') }}
|
||||||
@endif
|
@endif
|
||||||
@@ -93,7 +97,7 @@
|
|||||||
wire:click="managePolarSubscription"
|
wire:click="managePolarSubscription"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="w-full sm:w-auto cursor-pointer"
|
class="w-full sm:w-auto cursor-pointer sm:mr-2"
|
||||||
>
|
>
|
||||||
{{ __('Manage in Polar') }}
|
{{ __('Manage in Polar') }}
|
||||||
</flux:button>
|
</flux:button>
|
||||||
@@ -112,7 +116,7 @@
|
|||||||
</flux:button>
|
</flux:button>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
@if($latestActiveSubscription->isActive() && $latestActiveSubscription->provider !== 'activation_key')
|
@if($latestActiveSubscription->isActive() && in_array($latestActiveSubscription->provider, ['activation_key']))
|
||||||
<flux:button
|
<flux:button
|
||||||
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
wire:click="confirmCancelSubscription({{ $latestActiveSubscription->id }})"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|||||||
Reference in New Issue
Block a user