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.
This commit is contained in:
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PaymentProviders\Schemas;
|
namespace App\Filament\Resources\PaymentProviders\Schemas;
|
||||||
|
|
||||||
use Filament\Schemas\Components\Grid;
|
|
||||||
use Filament\Forms\Components\KeyValue;
|
use Filament\Forms\Components\KeyValue;
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
class PaymentProviderForm
|
class PaymentProviderForm
|
||||||
@@ -101,23 +101,6 @@ class PaymentProviderForm
|
|||||||
->helperText('Example: fixed_fee, percentage_fee')
|
->helperText('Example: fixed_fee, percentage_fee')
|
||||||
->columnSpanFull(),
|
->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(),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,10 +146,34 @@ class PlanResource extends Resource
|
|||||||
->money('USD')
|
->money('USD')
|
||||||
->sortable(),
|
->sortable(),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('billing_cycle_display')
|
Tables\Columns\TextColumn::make('billing_cycle_days')
|
||||||
->label('Billing Cycle')
|
->label('Billing Cycle')
|
||||||
->badge()
|
->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')
|
Tables\Columns\IconColumn::make('is_active')
|
||||||
->label('Active')
|
->label('Active')
|
||||||
@@ -157,19 +181,29 @@ class PlanResource extends Resource
|
|||||||
->trueColor('success')
|
->trueColor('success')
|
||||||
->falseColor('danger'),
|
->falseColor('danger'),
|
||||||
|
|
||||||
Tables\Columns\TextColumn::make('planProviders_count')
|
Tables\Columns\TextColumn::make('plan_providers_count')
|
||||||
->label('Providers')
|
->label('Providers')
|
||||||
->counts('planProviders')
|
->getStateUsing(fn ($record) => $record->plan_providers_count ?? 0)
|
||||||
->badge()
|
->badge()
|
||||||
->color('info')
|
->color(fn ($record) => match (true) {
|
||||||
->sortable(false),
|
$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')
|
->label('Features')
|
||||||
->counts('planFeatureLimits')
|
->getStateUsing(fn ($record) => $record->plan_feature_limits_count ?? 0)
|
||||||
->badge()
|
->badge()
|
||||||
->color('warning')
|
->color(fn ($record) => match (true) {
|
||||||
->sortable(false),
|
$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')
|
Tables\Columns\TextColumn::make('sort_order')
|
||||||
->label('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
|
public static function getRelations(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Filament\Resources\PlanResource\Pages;
|
namespace App\Filament\Resources\PlanResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PlanResource;
|
use App\Filament\Resources\PlanResource;
|
||||||
|
use App\Models\PaymentProvider;
|
||||||
use App\Models\PlanFeature;
|
use App\Models\PlanFeature;
|
||||||
use App\Models\TrialConfiguration;
|
use App\Models\TrialConfiguration;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@@ -188,23 +189,48 @@ class EditPlan extends EditRecord
|
|||||||
Grid::make(2)->schema([
|
Grid::make(2)->schema([
|
||||||
Select::make('provider')
|
Select::make('provider')
|
||||||
->label('Provider')
|
->label('Provider')
|
||||||
->options([
|
->options(function () {
|
||||||
'stripe' => 'Stripe',
|
$providers = PaymentProvider::orderBy('priority', 'desc')
|
||||||
'lemon_squeezy' => 'Lemon Squeezy',
|
->orderBy('name')
|
||||||
'polar' => 'Polar.sh',
|
->get();
|
||||||
'oxapay' => 'OxaPay',
|
|
||||||
'crypto' => 'Crypto',
|
if ($providers->isEmpty()) {
|
||||||
'activation_key' => 'Activation Key',
|
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()
|
->required()
|
||||||
->reactive()
|
->reactive()
|
||||||
->afterStateUpdated(function ($state, callable $set) {
|
->afterStateUpdated(function ($state, callable $set) {
|
||||||
match ($state) {
|
if ($state === 'no_providers') {
|
||||||
'stripe' => $set('provider_price_id_label', 'Stripe Price ID'),
|
$set('provider_price_id_label', 'N/A - Add providers first');
|
||||||
'lemon_squeezy' => $set('provider_price_id_label', 'Lemon Squeezy Variant ID'),
|
|
||||||
'polar' => $set('provider_price_id_label', 'Polar Product ID'),
|
return;
|
||||||
default => $set('provider_price_id_label', 'Provider ID'),
|
}
|
||||||
};
|
|
||||||
|
$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')
|
Toggle::make('is_enabled')
|
||||||
@@ -224,6 +250,16 @@ class EditPlan extends EditRecord
|
|||||||
->helperText('Override plan price for this provider'),
|
->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([
|
Grid::make(2)->schema([
|
||||||
TextInput::make('currency')
|
TextInput::make('currency')
|
||||||
->label('Currency')
|
->label('Currency')
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\Subscriptions\Schemas;
|
namespace App\Filament\Resources\Subscriptions\Schemas;
|
||||||
|
|
||||||
|
use App\Models\PaymentProvider;
|
||||||
use Filament\Forms\Components\DateTimePicker;
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
@@ -78,16 +79,50 @@ class SubscriptionForm
|
|||||||
->schema([
|
->schema([
|
||||||
Select::make('provider')
|
Select::make('provider')
|
||||||
->label('Provider')
|
->label('Provider')
|
||||||
->options([
|
->options(function () {
|
||||||
'stripe' => 'Stripe',
|
$providers = PaymentProvider::orderBy('priority', 'desc')
|
||||||
'lemon_squeezy' => 'Lemon Squeezy',
|
->orderBy('name')
|
||||||
'polar' => 'Polar.sh',
|
->get();
|
||||||
'oxapay' => 'OxaPay',
|
|
||||||
'crypto' => 'Crypto',
|
if ($providers->isEmpty()) {
|
||||||
'activation_key' => 'Activation Key',
|
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()
|
->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')
|
TextInput::make('provider_subscription_id')
|
||||||
->label('Provider Subscription ID'),
|
->label('Provider Subscription ID'),
|
||||||
@@ -265,7 +300,7 @@ class SubscriptionForm
|
|||||||
|
|
||||||
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
return json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
})
|
})
|
||||||
->state(fn ($record) => $record->provider_data)
|
->state(fn ($record) => $record?->provider_data ?? '{}')
|
||||||
->copyable()
|
->copyable()
|
||||||
->columnSpanFull(),
|
->columnSpanFull(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
Reference in New Issue
Block a user