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:
idevakk
2025-12-07 02:23:14 -08:00
parent 1b438cbf89
commit 5fabec1f9d
7 changed files with 225 additions and 93 deletions

View File

@@ -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();
}),
]),
])

View File

@@ -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(),
]),

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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
};
}
}

View File

@@ -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>

View File

@@ -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>