feat: implement comprehensive enhanced plan management system
- Create 7 new models with full relationships and business logic:
* PlanFeature: Define available features with categories and types
* PlanFeatureLimit: Manage usage limits per plan with trial overrides
* PlanPermission: Granular permissions system for features
* PlanProvider: Multi-provider payment configuration
* PlanTier: Hierarchical plan structure with upgrade paths
* PlanUsage: Real-time usage tracking and analytics
* TrialConfiguration: Advanced trial settings per plan
- Enhance Plan model with 25+ new methods:
* Feature checking: hasFeature(), canUseFeature(), getRemainingUsage()
* Permission system: hasPermission() with trial support
* Payment providers: getAllowedProviders(), supportsProvider()
* Trial management: hasTrial(), getTrialConfig()
* Upgrade paths: isUpgradeFrom(), getUpgradePath()
* Utility methods: getBillingCycleDisplay(), metadata handling
- Completely redesign PlanResource with tabbed interface:
* Basic Info: Core plan configuration with dynamic billing cycles
* Features & Limits: Dynamic feature management with trial overrides
* Payment Providers: Multi-provider configuration (Stripe, Lemon Squeezy, etc.)
* Trial Settings: Advanced trial configuration with always-visible toggle
- Create new Filament resources:
* PlanFeatureResource: Manage available features by category
* PlanTierResource: Hierarchical tier management with parent-child relationships
- Implement comprehensive data migration:
* Migrate legacy plan data to new enhanced system
* Create default features (mailbox accounts, email forwarding, etc.)
* Preserve existing payment provider configurations
* Set up trial configurations (disabled for legacy plans)
* Handle duplicate data gracefully with rollback support
- Add proper database constraints and indexes:
* Unique constraints on plan-feature relationships
* Foreign key constraints with cascade deletes
* Performance indexes for common queries
* JSON metadata columns for flexible configuration
- Fix trial configuration form handling:
* Add required validation for numeric fields
* Implement proper null handling with defaults
* Add type casting for all numeric fields
* Ensure database constraint compliance
This commit is contained in:
235
app/Filament/Resources/PlanFeatureResource.php
Normal file
235
app/Filament/Resources/PlanFeatureResource.php
Normal file
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\CreatePlanFeature;
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\EditPlanFeature;
|
||||
use App\Filament\Resources\PlanFeatureResource\Pages\ListPlanFeatures;
|
||||
use App\Models\PlanFeature;
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Forms;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Grid;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlanFeatureResource extends Resource
|
||||
{
|
||||
protected static ?string $model = PlanFeature::class;
|
||||
|
||||
protected static string|null|\BackedEnum $navigationIcon = 'heroicon-o-cube';
|
||||
|
||||
protected static string|null|\UnitEnum $navigationGroup = 'Subscription Management';
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Feature Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('Internal name used in code (e.g., email_forwarding)'),
|
||||
|
||||
Forms\Components\TextInput::make('display_name')
|
||||
->label('Display Name')
|
||||
->required()
|
||||
->maxLength(255)
|
||||
->helperText('User-friendly name shown in UI'),
|
||||
]),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\Select::make('category')
|
||||
->label('Category')
|
||||
->options([
|
||||
'core' => 'Core',
|
||||
'advanced' => 'Advanced',
|
||||
'premium' => 'Premium',
|
||||
])
|
||||
->required()
|
||||
->default('core'),
|
||||
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'boolean' => 'Boolean (On/Off)',
|
||||
'numeric' => 'Numeric (With Limits)',
|
||||
'toggle' => 'Toggle (Switch)',
|
||||
])
|
||||
->required()
|
||||
->default('boolean')
|
||||
->reactive()
|
||||
->afterStateUpdated(function ($state, callable $set) {
|
||||
if ($state === 'numeric') {
|
||||
$set('show_limit_info', true);
|
||||
} else {
|
||||
$set('show_limit_info', false);
|
||||
}
|
||||
}),
|
||||
]),
|
||||
|
||||
Forms\Components\Textarea::make('description')
|
||||
->label('Description')
|
||||
->rows(3)
|
||||
->maxLength(500)
|
||||
->helperText('Describe what this feature does'),
|
||||
|
||||
Grid::make(2)->schema([
|
||||
Forms\Components\Toggle::make('is_active')
|
||||
->label('Active')
|
||||
->default(true)
|
||||
->helperText('Feature is available for use in plans'),
|
||||
|
||||
Forms\Components\TextInput::make('sort_order')
|
||||
->label('Sort Order')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->helperText('Display order in lists'),
|
||||
]),
|
||||
|
||||
TextEntry::make('limit_info')
|
||||
->label('Numeric Feature Info')
|
||||
->state('For numeric features, you can set limits in plan configurations (e.g., 100 emails per month).')
|
||||
->visible(fn (callable $get) => $get('show_limit_info') ?? false),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('display_name')
|
||||
->label('Display Name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Internal Name')
|
||||
->searchable()
|
||||
->sortable()
|
||||
->badge()
|
||||
->color('gray'),
|
||||
|
||||
Tables\Columns\TextColumn::make('category')
|
||||
->label('Category')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'core' => 'primary',
|
||||
'advanced' => 'warning',
|
||||
'premium' => 'danger',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Type')
|
||||
->badge()
|
||||
->color(fn (string $state): string => match ($state) {
|
||||
'boolean' => 'success',
|
||||
'numeric' => 'info',
|
||||
'toggle' => 'primary',
|
||||
default => 'gray',
|
||||
}),
|
||||
|
||||
Tables\Columns\IconColumn::make('is_active')
|
||||
->label('Active')
|
||||
->boolean()
|
||||
->trueColor('success')
|
||||
->falseColor('danger'),
|
||||
|
||||
Tables\Columns\TextColumn::make('sort_order')
|
||||
->label('Order')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('category')
|
||||
->label('Category')
|
||||
->options([
|
||||
'core' => 'Core',
|
||||
'advanced' => 'Advanced',
|
||||
'premium' => 'Premium',
|
||||
]),
|
||||
|
||||
Tables\Filters\SelectFilter::make('type')
|
||||
->label('Type')
|
||||
->options([
|
||||
'boolean' => 'Boolean',
|
||||
'numeric' => 'Numeric',
|
||||
'toggle' => 'Toggle',
|
||||
]),
|
||||
|
||||
Tables\Filters\TernaryFilter::make('is_active')
|
||||
->label('Active Status')
|
||||
->placeholder('All features')
|
||||
->trueLabel('Active only')
|
||||
->falseLabel('Inactive only'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
DeleteAction::make()
|
||||
->before(function (PlanFeature $record) {
|
||||
// Prevent deletion if feature is used in plans
|
||||
if ($record->planFeatureLimits()->exists()) {
|
||||
Log::error('Cannot delete feature that is used in plans');
|
||||
}
|
||||
}),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make()
|
||||
->before(function ($records) {
|
||||
foreach ($records as $record) {
|
||||
if ($record->planFeatureLimits()->exists()) {
|
||||
Log::error('Cannot delete features that are used in planss');
|
||||
}
|
||||
}
|
||||
}),
|
||||
]),
|
||||
])
|
||||
->emptyStateActions([
|
||||
CreateAction::make(),
|
||||
])
|
||||
->defaultSort('sort_order', 'asc')
|
||||
->groups([
|
||||
Tables\Grouping\Group::make('category')
|
||||
->label('Category')
|
||||
->collapsible(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListPlanFeatures::route('/'),
|
||||
'create' => CreatePlanFeature::route('/create'),
|
||||
'edit' => EditPlanFeature::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return static::getModel()::active()->count();
|
||||
}
|
||||
|
||||
public static function getNavigationBadgeColor(): ?string
|
||||
{
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user