fix(plans): prevent deletion of plans with active subscriptions
- Fix bulk delete and individual delete actions using before() hook with halt() - Add daily/weekly billing cycle options to plan resource and Polar provider - Enhance payment confirmation with dynamic polling and loading states - Add graceful handling for deleted plans in subscription display - Update Polar provider to support dynamic billing cycles
This commit is contained in:
@@ -56,11 +56,13 @@ class PlanResource extends Resource
|
||||
Select::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
1 => 'Daily',
|
||||
7 => 'Weekly',
|
||||
30 => 'Monthly',
|
||||
90 => 'Quarterly',
|
||||
365 => 'Yearly',
|
||||
60 => 'Bi-Monthly',
|
||||
90 => 'Quarterly',
|
||||
180 => 'Semi-Annual',
|
||||
365 => 'Yearly',
|
||||
])
|
||||
->default(30)
|
||||
->required(),
|
||||
@@ -225,8 +227,12 @@ class PlanResource extends Resource
|
||||
Tables\Filters\SelectFilter::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
1 => 'Daily',
|
||||
7 => 'Weekly',
|
||||
30 => 'Monthly',
|
||||
60 => 'Bi-Monthly',
|
||||
90 => 'Quarterly',
|
||||
180 => 'Semi-Annual',
|
||||
365 => 'Yearly',
|
||||
]),
|
||||
|
||||
@@ -239,22 +245,76 @@ class PlanResource extends Resource
|
||||
ViewAction::make(),
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (Plan $record) {
|
||||
// Prevent deletion if plan has active subscriptions
|
||||
if ($record->subscriptions()->where('status', 'active')->exists()) {
|
||||
Log::error('Cannot delete plan with active subscriptions');
|
||||
->requiresConfirmation()
|
||||
->before(function (DeleteAction $action, Plan $record) {
|
||||
// Prevent deletion if plan has any subscriptions (active, cancelled, etc.)
|
||||
if ($record->subscriptions()->exists()) {
|
||||
Log::warning('Attempted to delete plan with existing subscriptions', [
|
||||
'plan_id' => $record->id,
|
||||
'plan_name' => $record->name,
|
||||
'subscription_count' => $record->subscriptions()->count(),
|
||||
]);
|
||||
|
||||
// Show warning notification
|
||||
\Filament\Notifications\Notification::make()
|
||||
->warning()
|
||||
->title('Cannot Delete Plan')
|
||||
->body("Plan '{$record->name}' has {$record->subscriptions()->count()} subscription(s). Please cancel or remove all subscriptions first.")
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
// Halt the deletion process
|
||||
$action->halt();
|
||||
}
|
||||
})
|
||||
->action(function (Plan $record) {
|
||||
// This action will only run if not halted
|
||||
$record->delete();
|
||||
|
||||
// Show success notification
|
||||
\Filament\Notifications\Notification::make()
|
||||
->success()
|
||||
->title('Plan Deleted')
|
||||
->body("Plan '{$record->name}' has been deleted successfully.")
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
->bulkActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
->requiresConfirmation()
|
||||
->before(function (DeleteBulkAction $action, $records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->subscriptions()->where('status', 'active')->exists()) {
|
||||
Log::error('Cannot delete plan(s) with active subscriptions');
|
||||
if ($record->subscriptions()->exists()) {
|
||||
Log::warning('Attempted to bulk delete plan with existing subscriptions', [
|
||||
'plan_id' => $record->id,
|
||||
'plan_name' => $record->name,
|
||||
'subscription_count' => $record->subscriptions()->count(),
|
||||
]);
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->warning()
|
||||
->title('Cannot Delete Plans')
|
||||
->body("Plan '{$record->name}' has {$record->subscriptions()->count()} subscription(s). Please cancel or remove all subscriptions first.")
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
// Halt the bulk deletion process
|
||||
$action->halt();
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
->action(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
$record->delete();
|
||||
}
|
||||
|
||||
\Filament\Notifications\Notification::make()
|
||||
->success()
|
||||
->title('Plans Deleted')
|
||||
->body(count($records).' plan(s) have been deleted successfully.')
|
||||
->send();
|
||||
}),
|
||||
]),
|
||||
])
|
||||
|
||||
@@ -62,12 +62,15 @@ class EditPlan extends EditRecord
|
||||
Select::make('billing_cycle_days')
|
||||
->label('Billing Cycle')
|
||||
->options([
|
||||
1 => 'Daily',
|
||||
7 => 'Weekly',
|
||||
30 => 'Monthly',
|
||||
90 => 'Quarterly',
|
||||
365 => 'Yearly',
|
||||
60 => 'Bi-Monthly',
|
||||
90 => 'Quarterly',
|
||||
180 => 'Semi-Annual',
|
||||
365 => 'Yearly',
|
||||
])
|
||||
->default(30)
|
||||
->required(),
|
||||
]),
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\Subscription;
|
||||
use App\Services\Payments\PaymentOrchestrator;
|
||||
use Illuminate\Contracts\View\Factory;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -25,6 +24,8 @@ class PaymentConfirmation extends Component
|
||||
|
||||
public $errorMessage = null;
|
||||
|
||||
public $isChecking = false;
|
||||
|
||||
protected $listeners = ['$refresh'];
|
||||
|
||||
public function mount($subscription = null, $sessionToken = null): void
|
||||
@@ -100,63 +101,41 @@ class PaymentConfirmation extends Component
|
||||
$this->redirect(route('dashboard'));
|
||||
}
|
||||
|
||||
$this->isChecking = false;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
$this->isChecking = true;
|
||||
|
||||
// Increment poll count first
|
||||
$this->pollCount++;
|
||||
|
||||
try {
|
||||
$orchestrator = app(PaymentOrchestrator::class);
|
||||
$user = auth()->user();
|
||||
|
||||
Log::info('PaymentConfirmation: Checking subscription status', [
|
||||
Log::info('PaymentConfirmation: Syncing subscription with provider', [
|
||||
'subscription_id' => $this->subscription->id,
|
||||
'provider_subscription_id' => $this->subscription->provider_subscription_id,
|
||||
'provider' => $this->subscription->provider,
|
||||
'poll_count' => $this->pollCount,
|
||||
]);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// Use the same sync method as the billing page
|
||||
$syncSuccess = $this->subscription->syncWithProvider();
|
||||
|
||||
$statusResult = $orchestrator->checkSubscriptionStatus(
|
||||
$user,
|
||||
$this->subscription->provider,
|
||||
$this->subscription->provider_subscription_id
|
||||
);
|
||||
// Refresh the subscription from database to get updated status
|
||||
$this->subscription->refresh();
|
||||
|
||||
if ($statusResult['success']) {
|
||||
$providerStatus = $statusResult['status'];
|
||||
|
||||
Log::info('PaymentConfirmation: Provider status received', [
|
||||
'provider_status' => $providerStatus,
|
||||
Log::info('PaymentConfirmation: Subscription sync completed', [
|
||||
'subscription_id' => $this->subscription->id,
|
||||
'sync_success' => $syncSuccess,
|
||||
'current_status' => $this->subscription->status,
|
||||
'provider_subscription_id' => $this->subscription->provider_subscription_id,
|
||||
]);
|
||||
|
||||
// Update local subscription if status changed
|
||||
if ($providerStatus !== $this->subscription->status) {
|
||||
$this->subscription->status = $providerStatus;
|
||||
$this->subscription->save();
|
||||
|
||||
Log::info('PaymentConfirmation: Updated local subscription status', [
|
||||
'old_status' => $this->subscription->getOriginal('status'),
|
||||
'new_status' => $providerStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
// Check if subscription is now active
|
||||
if ($providerStatus === 'active') {
|
||||
if (in_array($this->subscription->status, ['active', 'trialing'])) {
|
||||
$this->status = 'activated';
|
||||
$this->showConfetti = true;
|
||||
|
||||
@@ -165,30 +144,17 @@ class PaymentConfirmation extends Component
|
||||
|
||||
Log::info('PaymentConfirmation: Subscription activated successfully', [
|
||||
'subscription_id' => $this->subscription->id,
|
||||
'final_status' => $this->subscription->status,
|
||||
]);
|
||||
|
||||
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
|
||||
if ($this->pollCount < $this->maxPolls) {
|
||||
$this->status = 'verifying';
|
||||
} else {
|
||||
// Max polls reached, check final status
|
||||
// Max polls reached, determine final status
|
||||
$this->status = in_array($this->subscription->status, ['active', 'trialing'])
|
||||
? 'activated'
|
||||
: 'pending';
|
||||
@@ -200,7 +166,7 @@ class PaymentConfirmation extends Component
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PaymentConfirmation: Error checking subscription status', [
|
||||
Log::error('PaymentConfirmation: Error syncing subscription', [
|
||||
'subscription_id' => $this->subscription->id,
|
||||
'error' => $e->getMessage(),
|
||||
'poll_count' => $this->pollCount,
|
||||
@@ -211,6 +177,9 @@ class PaymentConfirmation extends Component
|
||||
$this->errorMessage = 'Unable to verify payment status after multiple attempts. Please check your subscription page.';
|
||||
$this->status = 'error';
|
||||
}
|
||||
} finally {
|
||||
// Always reset loading state
|
||||
$this->isChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -690,4 +690,59 @@ class Subscription extends Model
|
||||
// Fallback to legacy monthly_billing
|
||||
return $this->plan && $this->plan->monthly_billing ? 30 : 365;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan display name, handles deleted plans gracefully
|
||||
*/
|
||||
public function getPlanDisplayName(): string
|
||||
{
|
||||
if ($this->plan) {
|
||||
return $this->plan->name;
|
||||
}
|
||||
|
||||
// Check provider data for stored plan name
|
||||
if ($this->provider_data) {
|
||||
// Check for Polar plan name
|
||||
if (isset($this->provider_data['polar_subscription']['product']['name'])) {
|
||||
return $this->provider_data['polar_subscription']['product']['name'];
|
||||
}
|
||||
|
||||
// Check for stored plan details
|
||||
if (isset($this->provider_data['plan_details']['name'])) {
|
||||
return $this->provider_data['plan_details']['name'];
|
||||
}
|
||||
|
||||
// Check metadata
|
||||
if (isset($this->provider_data['plan_name'])) {
|
||||
return $this->provider_data['plan_name'];
|
||||
}
|
||||
}
|
||||
|
||||
return 'Deleted Plan';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan price, handles deleted plans gracefully
|
||||
*/
|
||||
public function getPlanPrice(): float
|
||||
{
|
||||
if ($this->plan) {
|
||||
return $this->plan->price;
|
||||
}
|
||||
|
||||
// Check provider data for stored plan price
|
||||
if ($this->provider_data) {
|
||||
// Check for Polar subscription amount
|
||||
if (isset($this->provider_data['polar_subscription']['amount'])) {
|
||||
return $this->provider_data['polar_subscription']['amount'] / 100; // Convert from cents
|
||||
}
|
||||
|
||||
// Check for stored plan details
|
||||
if (isset($this->provider_data['plan_details']['price'])) {
|
||||
return (float) $this->provider_data['plan_details']['price'];
|
||||
}
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1106,24 +1106,28 @@ class PolarProvider implements PaymentProviderContract
|
||||
]);
|
||||
}
|
||||
|
||||
// Convert billing cycle to Polar's format
|
||||
$billingInterval = $this->convertBillingCycleToPolarInterval($plan->billing_cycle_days ?? 30);
|
||||
|
||||
// Create new product with correct structure
|
||||
$productData = [
|
||||
'name' => $plan->name,
|
||||
'description' => $plan->description ?? 'Subscription plan',
|
||||
'recurring_interval' => 'month',
|
||||
'recurring_interval_count' => 1,
|
||||
'recurring_interval' => $billingInterval['interval'],
|
||||
'recurring_interval_count' => $billingInterval['interval_count'],
|
||||
'prices' => [
|
||||
[
|
||||
'amount_type' => 'fixed',
|
||||
'price_amount' => (int) ($plan->price * 100), // Convert to cents
|
||||
'price_currency' => 'usd',
|
||||
'recurring_interval' => 'month',
|
||||
'recurring_interval_count' => 1,
|
||||
'recurring_interval' => $billingInterval['interval'],
|
||||
'recurring_interval_count' => $billingInterval['interval_count'],
|
||||
],
|
||||
],
|
||||
'metadata' => [
|
||||
'plan_id' => $plan->id,
|
||||
'plan_name' => $plan->name,
|
||||
'billing_cycle_days' => $plan->billing_cycle_days ?? 30,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -2361,4 +2365,21 @@ class PolarProvider implements PaymentProviderContract
|
||||
// For now, return basic stats - could be expanded with database tracking
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert billing cycle days to Polar's recurring interval format
|
||||
*/
|
||||
protected function convertBillingCycleToPolarInterval(int $billingCycleDays): array
|
||||
{
|
||||
return match ($billingCycleDays) {
|
||||
1 => ['interval' => 'day', 'interval_count' => 1], // Daily
|
||||
7 => ['interval' => 'week', 'interval_count' => 1], // Weekly
|
||||
30 => ['interval' => 'month', 'interval_count' => 1], // Monthly
|
||||
60 => ['interval' => 'month', 'interval_count' => 2], // Bi-monthly
|
||||
90 => ['interval' => 'month', 'interval_count' => 3], // Quarterly
|
||||
180 => ['interval' => 'month', 'interval_count' => 6], // Semi-annual
|
||||
365 => ['interval' => 'year', 'interval_count' => 1], // Yearly
|
||||
default => ['interval' => 'month', 'interval_count' => 1], // Default to monthly
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,8 +27,32 @@
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">
|
||||
Check {{ $pollCount }} of {{ $maxPolls }} • Retrying in 30 seconds...
|
||||
Check {{ $pollCount }} of {{ $maxPolls }} • Checking every 30 seconds...
|
||||
</p>
|
||||
|
||||
@if ($pollCount > 1)
|
||||
<p class="text-xs text-gray-400 dark:text-gray-600 mt-1">
|
||||
This is taking longer than expected. Webhook processing may be delayed.
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<button wire:click="checkSubscriptionStatus"
|
||||
wire:loading.attr="disabled"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
@if ($isChecking)
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Checking...
|
||||
@else
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Check Now
|
||||
@endif
|
||||
</button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -198,11 +222,11 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<!-- Polling Indicator - temporarily disabled -->
|
||||
<!-- Polling Indicator -->
|
||||
@if ($this->shouldContinuePolling)
|
||||
<!-- <div wire:poll.30000ms
|
||||
<div wire:poll.30000ms="checkSubscriptionStatus"
|
||||
wire:key="subscription-poll-{{ $subscription->id ?? 'none' }}-{{ $pollCount }}">
|
||||
</div> -->
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="3">{{ $latestActiveSubscription->plan->name }}</flux:heading>
|
||||
<flux:heading level="3">{{ $latestActiveSubscription->getPlanDisplayName() }}</flux:heading>
|
||||
<flux:badge variant="success">
|
||||
{{ ucfirst($latestActiveSubscription->status) }}
|
||||
</flux:badge>
|
||||
@@ -62,7 +62,7 @@
|
||||
</div>
|
||||
|
||||
<flux:text class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{{ $latestActiveSubscription->plan->description ?? 'Subscription plan' }}
|
||||
{{ $latestActiveSubscription->plan?->description ?? 'Subscription plan' }}
|
||||
</flux:text>
|
||||
|
||||
@if($latestActiveSubscription->isActive())
|
||||
@@ -152,7 +152,7 @@
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<flux:heading level="4">{{ $subscription->plan->name }}</flux:heading>
|
||||
<flux:heading level="4">{{ $subscription->getPlanDisplayName() }}</flux:heading>
|
||||
<flux:badge
|
||||
variant="{{ $subscription->isActive() ? 'success' : ($subscription->isCancelled() ? 'danger' : 'warning') }}"
|
||||
>
|
||||
@@ -190,7 +190,7 @@
|
||||
|
||||
<div class="text-right">
|
||||
<flux:text class="text-lg font-semibold">
|
||||
${{ number_format($subscription->plan->price, 2) }}
|
||||
${{ number_format($subscription->getPlanPrice(), 2) }}
|
||||
</flux:text>
|
||||
|
||||
@if($isWithin30Minutes && $subscription->id === $latestSubscription->id)
|
||||
@@ -232,7 +232,7 @@
|
||||
|
||||
@if($subscriptionToCancel)
|
||||
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-md">
|
||||
<flux:text class="font-medium">{{ $subscriptionToCancel->plan->name }}</flux:text>
|
||||
<flux:text class="font-medium">{{ $subscriptionToCancel->getPlanDisplayName() }}</flux:text>
|
||||
<flux:text class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ $subscriptionToCancel->getProviderDisplayName() }}
|
||||
</flux:text>
|
||||
|
||||
Reference in New Issue
Block a user