From d767c6cf59b8f435ddc2b6d85b6e24ce8e47ed0a Mon Sep 17 00:00:00 2001 From: idevakk <219866223+idevakk@users.noreply.github.com> Date: Tue, 2 Dec 2025 09:47:51 -0800 Subject: [PATCH] feat(plans): enhance plan management with dynamic providers, improved UI, and bug fixes Dynamic Provider Integration: - Replace hardcoded provider arrays with database-driven payment_providers lookup - Display provider status (Active/Inactive) in selection dropdowns - Add provider_variant_id and provider_product_id input fields to plan configuration - Update EditPlan and SubscriptionForm with dynamic provider selection - Add empty state handling with helpful guidance when no providers exist UI/UX Improvements: - Format billing_cycle_days to readable text (Daily, Weekly, Monthly, Quarterly, Annually) - Add color-coded badges for billing cycle frequency - Fix plan_providers and plan_feature_limits count display with eager loading - Implement intelligent color coding for count indicators - Add visual status indicators for provider availability Database Compatibility: - Fix SQLite strftime() compatibility across all dashboard widgets - Fix CAST AS REAL syntax in ChurnAnalysis widget - Add database-agnostic date and cast expression methods - Support MySQL, SQLite, PostgreSQL, and SQL Server Bug Fixes: - Fix null reference error in SubscriptionForm provider_data access - Add null safety checks for new subscription creation - Optimize queries with withCount() to prevent N+1 issues Performance Optimizations: - Add eager loading with withCount() for relationship counts - Optimize plan provider and feature limit queries - Prevent N+1 query issues in resource tables BREAKING CHANGE: Plan provider configuration now uses dynamic provider options from payment_providers table instead of hardcoded list. --- .../Schemas/PaymentProviderForm.php | 21 +----- app/Filament/Resources/PlanResource.php | 63 +++++++++++++++--- .../Resources/PlanResource/Pages/EditPlan.php | 64 +++++++++++++++---- .../Schemas/SubscriptionForm.php | 55 +++++++++++++--- 4 files changed, 150 insertions(+), 53 deletions(-) diff --git a/app/Filament/Resources/PaymentProviders/Schemas/PaymentProviderForm.php b/app/Filament/Resources/PaymentProviders/Schemas/PaymentProviderForm.php index 1736e1c..73f8694 100644 --- a/app/Filament/Resources/PaymentProviders/Schemas/PaymentProviderForm.php +++ b/app/Filament/Resources/PaymentProviders/Schemas/PaymentProviderForm.php @@ -2,12 +2,12 @@ namespace App\Filament\Resources\PaymentProviders\Schemas; -use Filament\Schemas\Components\Grid; use Filament\Forms\Components\KeyValue; -use Filament\Schemas\Components\Section; use Filament\Forms\Components\Textarea; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; class PaymentProviderForm @@ -101,23 +101,6 @@ class PaymentProviderForm ->helperText('Example: fixed_fee, percentage_fee') ->columnSpanFull(), ]), - - Section::make('Webhook Settings') - ->schema([ - Grid::make(2) - ->schema([ - TextInput::make('webhook_url') - ->label('Webhook URL') - ->url() - ->helperText('Endpoint for provider webhooks'), - - TextInput::make('webhook_secret') - ->label('Webhook Secret') - ->password() - ->helperText('Secret for webhook validation'), - ]), - ]) - ->collapsible(), ]); } } diff --git a/app/Filament/Resources/PlanResource.php b/app/Filament/Resources/PlanResource.php index 656eece..7c559b9 100644 --- a/app/Filament/Resources/PlanResource.php +++ b/app/Filament/Resources/PlanResource.php @@ -146,10 +146,34 @@ class PlanResource extends Resource ->money('USD') ->sortable(), - Tables\Columns\TextColumn::make('billing_cycle_display') + Tables\Columns\TextColumn::make('billing_cycle_days') ->label('Billing Cycle') ->badge() - ->color('primary'), + ->color(fn (int $state): string => match ($state) { + 1 => 'success', // Daily - green + 7 => 'info', // Weekly - blue + 14 => 'primary', // Bi-weekly - primary blue + 30 => 'warning', // Monthly - orange + 60 => 'gray', // Bi-monthly - gray + 90 => 'purple', // Quarterly - purple + 180 => 'danger', // Semi-annually - red + 365 => 'primary', // Annually - primary blue + 730 => 'gray', // Biennially - gray + default => 'gray' // Custom cycles - gray + }) + ->formatStateUsing(fn (int $state): string => match ($state) { + 1 => 'Daily', + 7 => 'Weekly', + 14 => 'Bi-weekly', + 30 => 'Monthly', + 60 => 'Bi-monthly', + 90 => 'Quarterly', + 180 => 'Semi-annually', + 365 => 'Annually', + 730 => 'Biennially', + default => "{$state} days" + }) + ->sortable(), Tables\Columns\IconColumn::make('is_active') ->label('Active') @@ -157,19 +181,29 @@ class PlanResource extends Resource ->trueColor('success') ->falseColor('danger'), - Tables\Columns\TextColumn::make('planProviders_count') + Tables\Columns\TextColumn::make('plan_providers_count') ->label('Providers') - ->counts('planProviders') + ->getStateUsing(fn ($record) => $record->plan_providers_count ?? 0) ->badge() - ->color('info') - ->sortable(false), + ->color(fn ($record) => match (true) { + $record->plan_providers_count === 0 => 'danger', + $record->plan_providers_count === 1 => 'warning', + $record->plan_providers_count >= 3 => 'success', + default => 'info' + }) + ->sortable(), - Tables\Columns\TextColumn::make('planFeatureLimits_count') + Tables\Columns\TextColumn::make('plan_feature_limits_count') ->label('Features') - ->counts('planFeatureLimits') + ->getStateUsing(fn ($record) => $record->plan_feature_limits_count ?? 0) ->badge() - ->color('warning') - ->sortable(false), + ->color(fn ($record) => match (true) { + $record->plan_feature_limits_count === 0 => 'danger', + $record->plan_feature_limits_count >= 5 => 'success', + $record->plan_feature_limits_count >= 3 => 'info', + default => 'warning' + }) + ->sortable(), Tables\Columns\TextColumn::make('sort_order') ->label('Order') @@ -235,6 +269,15 @@ class PlanResource extends Resource ]); } + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withCount([ + 'planProviders', + 'planFeatureLimits', + ]); + } + public static function getRelations(): array { return [ diff --git a/app/Filament/Resources/PlanResource/Pages/EditPlan.php b/app/Filament/Resources/PlanResource/Pages/EditPlan.php index 9d76da7..b1475e1 100644 --- a/app/Filament/Resources/PlanResource/Pages/EditPlan.php +++ b/app/Filament/Resources/PlanResource/Pages/EditPlan.php @@ -3,6 +3,7 @@ namespace App\Filament\Resources\PlanResource\Pages; use App\Filament\Resources\PlanResource; +use App\Models\PaymentProvider; use App\Models\PlanFeature; use App\Models\TrialConfiguration; use Filament\Actions; @@ -188,23 +189,48 @@ class EditPlan extends EditRecord Grid::make(2)->schema([ Select::make('provider') ->label('Provider') - ->options([ - 'stripe' => 'Stripe', - 'lemon_squeezy' => 'Lemon Squeezy', - 'polar' => 'Polar.sh', - 'oxapay' => 'OxaPay', - 'crypto' => 'Crypto', - 'activation_key' => 'Activation Key', - ]) + ->options(function () { + $providers = PaymentProvider::orderBy('priority', 'desc') + ->orderBy('name') + ->get(); + + if ($providers->isEmpty()) { + return [ + 'no_providers' => '⚠️ No payment providers configured - Please add providers first', + ]; + } + + return $providers->mapWithKeys(function ($provider) { + $status = $provider->is_active ? 'Active' : 'Inactive'; + + return [ + $provider->name => "{$provider->display_name} ({$status})", + ]; + })->toArray(); + }) ->required() ->reactive() ->afterStateUpdated(function ($state, callable $set) { - match ($state) { - 'stripe' => $set('provider_price_id_label', 'Stripe Price ID'), - 'lemon_squeezy' => $set('provider_price_id_label', 'Lemon Squeezy Variant ID'), - 'polar' => $set('provider_price_id_label', 'Polar Product ID'), - default => $set('provider_price_id_label', 'Provider ID'), - }; + if ($state === 'no_providers') { + $set('provider_price_id_label', 'N/A - Add providers first'); + + return; + } + + $provider = PaymentProvider::where('name', $state)->first(); + $label = $provider ? $provider->display_name : 'Provider'; + $set('provider_price_id_label', "{$label} ID"); + }) + ->helperText(function (callable $get) { + $providers = PaymentProvider::orderBy('priority', 'desc') + ->orderBy('name') + ->get(); + + if ($providers->isEmpty()) { + return '⚠️ No payment providers available. Please configure payment providers in the Payment Providers section first.'; + } + + return 'Select a payment provider for this plan'; }), Toggle::make('is_enabled') @@ -224,6 +250,16 @@ class EditPlan extends EditRecord ->helperText('Override plan price for this provider'), ]), + Grid::make(2)->schema([ + TextInput::make('provider_variant_id') + ->label('Provider Variant ID') + ->helperText('Variant ID from the provider (if applicable)'), + + TextInput::make('provider_product_id') + ->label('Provider Product ID') + ->helperText('Product ID from the provider (if applicable)'), + ]), + Grid::make(2)->schema([ TextInput::make('currency') ->label('Currency') diff --git a/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php b/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php index 466649b..c1c3c28 100644 --- a/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php +++ b/app/Filament/Resources/Subscriptions/Schemas/SubscriptionForm.php @@ -2,6 +2,7 @@ namespace App\Filament\Resources\Subscriptions\Schemas; +use App\Models\PaymentProvider; use Filament\Forms\Components\DateTimePicker; use Filament\Forms\Components\Select; use Filament\Forms\Components\Textarea; @@ -78,16 +79,50 @@ class SubscriptionForm ->schema([ Select::make('provider') ->label('Provider') - ->options([ - 'stripe' => 'Stripe', - 'lemon_squeezy' => 'Lemon Squeezy', - 'polar' => 'Polar.sh', - 'oxapay' => 'OxaPay', - 'crypto' => 'Crypto', - 'activation_key' => 'Activation Key', - ]) + ->options(function () { + $providers = PaymentProvider::orderBy('priority', 'desc') + ->orderBy('name') + ->get(); + + if ($providers->isEmpty()) { + return [ + 'no_providers' => '⚠️ No payment providers configured - Please add providers first', + ]; + } + + return $providers->mapWithKeys(function ($provider) { + $status = $provider->is_active ? 'Active' : 'Inactive'; + + return [ + $provider->name => "{$provider->display_name} ({$status})", + ]; + })->toArray(); + }) ->required() - ->default('stripe'), + ->default(function () { + $providers = PaymentProvider::orderBy('priority', 'desc') + ->orderBy('name') + ->get(); + + if ($providers->isEmpty()) { + return 'no_providers'; + } + + return PaymentProvider::where('name', 'stripe')->exists() + ? 'stripe' + : PaymentProvider::active()->first()?->name; + }) + ->helperText(function () { + $providers = PaymentProvider::orderBy('priority', 'desc') + ->orderBy('name') + ->get(); + + if ($providers->isEmpty()) { + return '⚠️ No payment providers available. Please configure payment providers in the Payment Providers section first.'; + } + + return 'Select a payment provider for this subscription'; + }), TextInput::make('provider_subscription_id') ->label('Provider Subscription ID'), @@ -265,7 +300,7 @@ class SubscriptionForm return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); }) - ->state(fn ($record) => $record->provider_data) + ->state(fn ($record) => $record?->provider_data ?? '{}') ->copyable() ->columnSpanFull(), ]),