diff --git a/app/Filament/Resources/PlanFeatureResource.php b/app/Filament/Resources/PlanFeatureResource.php new file mode 100644 index 0000000..5d4ffd1 --- /dev/null +++ b/app/Filament/Resources/PlanFeatureResource.php @@ -0,0 +1,235 @@ +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'; + } +} diff --git a/app/Filament/Resources/PlanFeatureResource/Pages/CreatePlanFeature.php b/app/Filament/Resources/PlanFeatureResource/Pages/CreatePlanFeature.php new file mode 100644 index 0000000..3a45f1d --- /dev/null +++ b/app/Filament/Resources/PlanFeatureResource/Pages/CreatePlanFeature.php @@ -0,0 +1,16 @@ +getResource()::getUrl('index'); + } +} diff --git a/app/Filament/Resources/PlanFeatureResource/Pages/EditPlanFeature.php b/app/Filament/Resources/PlanFeatureResource/Pages/EditPlanFeature.php new file mode 100644 index 0000000..126ff05 --- /dev/null +++ b/app/Filament/Resources/PlanFeatureResource/Pages/EditPlanFeature.php @@ -0,0 +1,40 @@ +before(function (PlanFeature $record) { + // Prevent deletion if feature is used in plans + if ($record->planFeatureLimits()->exists()) { + throw new \Exception('Cannot delete feature that is used in plans'); + } + }), + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function getSavedNotification(): ?Notification + { + return Notification::make() + ->success() + ->title('Feature updated') + ->body('Plan feature updated successfully'); + } +} diff --git a/app/Filament/Resources/PlanFeatureResource/Pages/ListPlanFeatures.php b/app/Filament/Resources/PlanFeatureResource/Pages/ListPlanFeatures.php new file mode 100644 index 0000000..e350059 --- /dev/null +++ b/app/Filament/Resources/PlanFeatureResource/Pages/ListPlanFeatures.php @@ -0,0 +1,19 @@ +components([ - Section::make('Plan Information') - ->description('Add a new plan') + Grid::make(3)->schema([ + TextInput::make('name') + ->label('Plan Name') + ->required() + ->maxLength(255), + + TextInput::make('price') + ->label('Price') + ->numeric() + ->prefix('$') + ->required(), + + Select::make('billing_cycle_days') + ->label('Billing Cycle') + ->options([ + 30 => 'Monthly', + 90 => 'Quarterly', + 365 => 'Yearly', + 60 => 'Bi-Monthly', + 180 => 'Semi-Annual', + ]) + ->default(30) + ->required(), + ]), + + Grid::make(2)->schema([ + TextInput::make('product_id') + ->label('Product ID') + ->required() + ->helperText('External product identifier'), + + TextInput::make('pricing_id') + ->label('Pricing ID') + ->required() + ->helperText('External pricing identifier'), + ]), + + Textarea::make('description') + ->label('Description') + ->rows(3) + ->maxLength(500), + + Grid::make(3)->schema([ + Select::make('plan_tier_id') + ->label('Plan Tier') + ->options(PlanTier::pluck('name', 'id')) + ->nullable() + ->searchable() + ->helperText('Optional tier classification'), + + Toggle::make('is_active') + ->label('Active') + ->default(true) + ->helperText('Plan is available for new subscriptions'), + + TextInput::make('sort_order') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Display order in pricing tables'), + ]), + + Section::make('Legacy Settings') + ->description('Legacy payment provider settings (will be migrated to new system)') + ->collapsible() ->schema([ - TextInput::make('name')->label('Page Name') - ->required(), - TextInput::make('description'), - TextInput::make('product_id')->required(), - TextInput::make('pricing_id')->required(), - TextInput::make('shoppy_product_id')->nullable(), - TextInput::make('oxapay_link')->nullable(), - TextInput::make('price')->numeric()->required(), - TextInput::make('mailbox_limit')->numeric()->required(), - Select::make('monthly_billing')->options([ - 1 => 'Monthly', - 0 => 'Yearly', - ])->required(), - Select::make('accept_stripe')->options([ - 1 => 'Activate', - 0 => 'Disable', - ])->required(), - Select::make('accept_shoppy')->options([ - 1 => 'Activate', - 0 => 'Disable', - ])->required(), - Select::make('accept_oxapay')->options([ - 1 => 'Activate', - 0 => 'Disable', - ])->required(), - KeyValue::make('details') - ->label('Plan Details (Optional)') - ->keyPlaceholder('Name') - ->valuePlaceholder('Content') - ->reorderable(), + Grid::make(3)->schema([ + Toggle::make('monthly_billing') + ->label('Monthly Billing (Legacy)') + ->helperText('Legacy monthly billing flag'), + + TextInput::make('mailbox_limit') + ->label('Mailbox Limit') + ->numeric() + ->default(10) + ->helperText('Maximum number of mailboxes'), + + TextInput::make('shoppy_product_id') + ->label('Shoppy Product ID') + ->nullable(), + ]), ]), ]); } @@ -78,46 +130,108 @@ class PlanResource extends Resource { return $table ->columns([ - TextColumn::make('name')->label('Name')->searchable(), - TextColumn::make('product_id')->label('Product'), - TextColumn::make('pricing_id')->label('Pricing'), - TextColumn::make('price')->label('Price'), - BooleanColumn::make('monthly_billing')->label('Monthly Billing'), - ]) - ->searchable() - ->filters([ - SelectFilter::make('payment_method') - ->label('Payment Method') - ->options([ - 'stripe' => 'Stripe', - 'shoppy' => 'Shoppy', - 'oxapay' => 'OxaPay', - ]) - ->query(function ($query, array $data) { - if (isset($data['value'])) { - if ($data['value'] === 'stripe') { - return $query->where('accept_stripe', true); - } - if ($data['value'] === 'shoppy') { - return $query->where('accept_shoppy', true); - } - if ($data['value'] === 'oxapay') { - return $query->where('accept_oxapay', true); - } - } + Tables\Columns\TextColumn::make('name') + ->label('Plan Name') + ->searchable() + ->sortable(), - return $query; - }), + Tables\Columns\TextColumn::make('planTier.name') + ->label('Tier') + ->badge() + ->sortable() + ->placeholder('No Tier'), + + Tables\Columns\TextColumn::make('price') + ->label('Price') + ->money('USD') + ->sortable(), + + Tables\Columns\TextColumn::make('billing_cycle_display') + ->label('Billing Cycle') + ->badge() + ->color('primary'), + + Tables\Columns\IconColumn::make('is_active') + ->label('Active') + ->boolean() + ->trueColor('success') + ->falseColor('danger'), + + Tables\Columns\TextColumn::make('planProviders_count') + ->label('Providers') + ->counts('planProviders') + ->badge() + ->color('info') + ->sortable(false), + + Tables\Columns\TextColumn::make('planFeatureLimits_count') + ->label('Features') + ->counts('planFeatureLimits') + ->badge() + ->color('warning') + ->sortable(false), + + Tables\Columns\TextColumn::make('sort_order') + ->label('Order') + ->sortable() + ->alignCenter(), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('plan_tier_id') + ->label('Tier') + ->options(PlanTier::pluck('name', 'id')) + ->searchable(), + + Tables\Filters\TernaryFilter::make('is_active') + ->label('Active Status') + ->placeholder('All plans') + ->trueLabel('Active only') + ->falseLabel('Inactive only'), + + Tables\Filters\SelectFilter::make('billing_cycle_days') + ->label('Billing Cycle') + ->options([ + 30 => 'Monthly', + 90 => 'Quarterly', + 365 => 'Yearly', + ]), + + Tables\Filters\Filter::make('has_providers') + ->label('Has Payment Providers') + ->query(fn (Builder $query): Builder => $query->whereHas('planProviders')) + ->toggle(), ]) ->recordActions([ ViewAction::make(), EditAction::make(), - DeleteAction::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'); + } + }), ]) ->toolbarActions([ BulkActionGroup::make([ - DeleteBulkAction::make(), + DeleteBulkAction::make() + ->before(function ($records) { + foreach ($records as $record) { + if ($record->subscriptions()->where('status', 'active')->exists()) { + Log::error('Cannot delete plan(s) with active subscriptions'); + } + } + }), ]), + ]) + ->emptyStateActions([ + CreateAction::make(), + ]) + ->defaultSort('sort_order', 'asc') + ->groups([ + Tables\Grouping\Group::make('planTier.name') + ->label('Tier') + ->collapsible(), ]); } @@ -136,4 +250,14 @@ class PlanResource extends Resource 'edit' => EditPlan::route('/{record}/edit'), ]; } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::active()->count(); + } + + public static function getNavigationBadgeColor(): ?string + { + return 'success'; + } } diff --git a/app/Filament/Resources/PlanResource/Pages/EditPlan.php b/app/Filament/Resources/PlanResource/Pages/EditPlan.php index f646a73..9d76da7 100644 --- a/app/Filament/Resources/PlanResource/Pages/EditPlan.php +++ b/app/Filament/Resources/PlanResource/Pages/EditPlan.php @@ -3,24 +3,360 @@ namespace App\Filament\Resources\PlanResource\Pages; use App\Filament\Resources\PlanResource; -use Filament\Actions\DeleteAction; +use App\Models\PlanFeature; +use App\Models\TrialConfiguration; +use Filament\Actions; +use Filament\Forms\Components\Repeater; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\Textarea; +use Filament\Forms\Components\TextInput; +use Filament\Forms\Components\Toggle; +use Filament\Infolists\Components\TextEntry; use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; +use Filament\Schemas\Components\Grid; +use Filament\Schemas\Components\Section; +use Filament\Schemas\Components\Tabs; +use Filament\Schemas\Components\Tabs\Tab; +use Filament\Schemas\Schema; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; class EditPlan extends EditRecord { protected static string $resource = PlanResource::class; + protected function mutateFormDataBeforeFill(array $data): array + { + // Load trial configuration if it exists + if ($this->record && $this->record->trialConfiguration) { + $data['trialConfiguration'] = $this->record->trialConfiguration->toArray(); + } + + return $data; + } + + public function form(Schema $schema): Schema + { + return $schema + ->components([ + // Main Plan Information Tab + Tabs::make('Plan Management') + ->tabs([ + Tab::make('Basic Info') + ->label('Plan Information') + ->schema([ + Grid::make(3)->schema([ + TextInput::make('name') + ->label('Plan Name') + ->required() + ->maxLength(255), + + TextInput::make('price') + ->label('Price') + ->numeric() + ->prefix('$') + ->required(), + + Select::make('billing_cycle_days') + ->label('Billing Cycle') + ->options([ + 30 => 'Monthly', + 90 => 'Quarterly', + 365 => 'Yearly', + 60 => 'Bi-Monthly', + 180 => 'Semi-Annual', + ]) + ->required(), + ]), + + Grid::make(2)->schema([ + TextInput::make('product_id') + ->label('Product ID') + ->required() + ->helperText('External product identifier'), + + TextInput::make('pricing_id') + ->label('Pricing ID') + ->required() + ->helperText('External pricing identifier'), + ]), + + Textarea::make('description') + ->label('Description') + ->rows(3) + ->maxLength(500), + + Grid::make(3)->schema([ + Select::make('plan_tier_id') + ->label('Plan Tier') + ->relationship('planTier', 'name') + ->nullable() + ->searchable() + ->helperText('Optional tier classification'), + + Toggle::make('is_active') + ->label('Active') + ->default(true) + ->helperText('Plan is available for new subscriptions'), + + TextInput::make('sort_order') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Display order in pricing tables'), + ]), + ]), + + // Features Management Tab + Tab::make('Features') + ->label('Features & Limits') + ->schema([ + TextEntry::make('features_help') + ->label('Feature Management') + ->state('Configure which features are available and their limits for this plan. You can also set different limits for trial periods.'), + + Repeater::make('planFeatureLimits') + ->label('Feature Limits') + ->relationship() + ->schema([ + Grid::make(2)->schema([ + Select::make('plan_feature_id') + ->label('Feature') + ->options(PlanFeature::active()->ordered()->pluck('display_name', 'id')) + ->required() + ->reactive() + ->afterStateUpdated(fn ($state, callable $set) => $set('feature_type', PlanFeature::find($state)?->type ?? 'boolean') + ), + + Select::make('limit_type') + ->label('Limit Type') + ->options([ + 'monthly' => 'Monthly', + 'daily' => 'Daily', + 'total' => 'Total', + ]) + ->default('monthly') + ->required(), + ]), + + Grid::make(2)->schema([ + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + + TextInput::make('limit_value') + ->label('Limit Value') + ->numeric() + ->placeholder('Unlimited if empty') + ->helperText('Leave empty for unlimited'), + ]), + + Section::make('Trial Settings') + ->description('Override settings for trial periods') + ->collapsed() + ->schema([ + Toggle::make('applies_during_trial') + ->label('Apply Limits During Trial') + ->default(true), + + TextInput::make('trial_limit_value') + ->label('Trial Limit Value') + ->numeric() + ->placeholder('Use regular limit if empty') + ->helperText('Different limit for trial period'), + ]), + ]) + ->columns(1) + ->collapsible() + ->itemLabel(fn (array $state): ?string => PlanFeature::find($state['plan_feature_id'] ?? null)?->display_name ?? 'New Feature' + ), + ]), + + // Payment Providers Tab + Tab::make('Providers') + ->label('Payment Providers') + ->schema([ + TextEntry::make('providers_help') + ->label('Payment Provider Configuration') + ->state('Configure which payment providers are available for this plan. Users will be able to choose from the enabled providers at checkout.'), + + Repeater::make('planProviders') + ->label('Payment Providers') + ->relationship() + ->schema([ + 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', + ]) + ->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'), + }; + }), + + Toggle::make('is_enabled') + ->label('Enabled') + ->default(true), + ]), + + Grid::make(2)->schema([ + TextInput::make('provider_price_id') + ->label(fn (callable $get) => $get('provider_price_id_label') ?? 'Provider Price ID') + ->helperText('Price/Variant ID from the provider'), + + TextInput::make('price') + ->label('Price Override') + ->numeric() + ->prefix('$') + ->helperText('Override plan price for this provider'), + ]), + + Grid::make(2)->schema([ + TextInput::make('currency') + ->label('Currency') + ->default('USD') + ->maxLength(3), + + TextInput::make('sort_order') + ->label('Display Order') + ->numeric() + ->default(0), + ]), + ]) + ->columns(1) + ->collapsible() + ->itemLabel(fn (array $state): ?string => $state['provider'] ?? 'New Provider'), + ]), + + // Trial Configuration Tab + Tab::make('Trials') + ->label('Trial Settings') + ->schema([ + TextEntry::make('trials_help') + ->label('Trial Configuration') + ->state('Configure trial settings for this plan. Trials are disabled by default and must be explicitly enabled.'), + + // Always visible: Enable/Disable toggle + Section::make('Trial Status') + ->description('Enable or disable trial functionality for this plan') + ->schema([ + Toggle::make('trialConfiguration.trial_enabled') + ->label('Enable Trials') + ->default(false) + ->reactive() + ->afterStateUpdated(function ($state, callable $set) { + if (! $state) { + $set('trialConfiguration.trial_duration_days', 14); + $set('trialConfiguration.trial_requires_payment_method', true); + $set('trialConfiguration.trial_auto_converts', true); + } + }) + ->helperText('Toggle to enable trial functionality for this plan'), + ]), + + // Trial configuration section - only visible when trials are enabled + Section::make('Trial Configuration') + ->description('Configure trial period, limits, and behavior settings') + ->collapsed() + ->schema([ + Grid::make(2)->schema([ + TextInput::make('trialConfiguration.trial_duration_days') + ->label('Trial Duration (Days)') + ->numeric() + ->default(14) + ->required() + ->helperText('Number of days for the trial period'), + + TextInput::make('trialConfiguration.trial_extension_limit') + ->label('Extension Limit') + ->numeric() + ->default(0) + ->required() + ->helperText('Maximum number of trial extensions allowed'), + ]), + + Grid::make(3)->schema([ + Toggle::make('trialConfiguration.trial_requires_payment_method') + ->label('Require Payment Method') + ->default(true) + ->helperText('Require payment method to start trial'), + + Toggle::make('trialConfiguration.trial_auto_converts') + ->label('Auto-convert to Paid') + ->default(true) + ->helperText('Automatically convert to paid plan when trial ends'), + + Select::make('trialConfiguration.trial_conversion_action') + ->label('Conversion Action') + ->options([ + 'upgrade_to_paid' => 'Upgrade to Paid', + 'cancel' => 'Cancel Subscription', + 'notify' => 'Notify Only', + ]) + ->required() + ->default('upgrade_to_paid') + ->helperText('Action to take when trial expires'), + ]), + + Section::make('Trial Messages') + ->description('Customize messages shown to users during trial period') + ->collapsed() + ->schema([ + Textarea::make('trialConfiguration.trial_welcome_message') + ->label('Welcome Message') + ->rows(2) + ->placeholder('Message shown when trial starts') + ->helperText('Displayed to users when they begin a trial'), + + Textarea::make('trialConfiguration.trial_expiry_message') + ->label('Expiry Message') + ->rows(2) + ->placeholder('Message shown when trial is about to expire') + ->helperText('Displayed to users when trial is ending'), + ]), + ]) + ->visible(fn (callable $get) => $get('trialConfiguration.trial_enabled') ?? false), + + TextEntry::make('trials_disabled_info') + ->label('Trials Currently Disabled') + ->state('Enable the "Enable Trials" toggle above to configure trial settings for this plan. Trial functionality allows users to try your plan before committing to a paid subscription.') + ->columnSpanFull() + ->visible(fn (callable $get) => ! ($get('trialConfiguration.trial_enabled') ?? false)), + ]), + ]) + ->columnSpanFull(), + ]); + } + protected function getHeaderActions(): array { return [ - DeleteAction::make(), + Actions\DeleteAction::make() + ->before(function (Model $record) { + // Prevent deletion if plan has active subscriptions + if ($record->subscriptions()->where('status', 'active')->exists()) { + Log::error('Cannot delete plan with active subscriptions'); + } + }), ]; } protected function getRedirectUrl(): ?string { - return $this->getResource()::getUrl('index'); + return self::getResource()::getUrl('index'); } protected function getSavedNotification(): ?Notification @@ -28,6 +364,26 @@ class EditPlan extends EditRecord return Notification::make() ->success() ->title('Plan updated') - ->body('Plan updated successfully'); + ->body('Plan configuration updated successfully'); + } + + protected function handleRecordUpdate(Model $record, array $data): Model + { + // Handle trial configuration + if (isset($data['trialConfiguration'])) { + $trialConfig = $record->trialConfiguration ?? new TrialConfiguration; + + // Ensure numeric fields have default values + $trialData = $data['trialConfiguration']; + $trialData['trial_extension_limit'] = $trialData['trial_extension_limit'] ?? 0; + $trialData['trial_duration_days'] = $trialData['trial_duration_days'] ?? 14; + + $trialConfig->fill($trialData); + $trialConfig->plan_id = $record->id; + $trialConfig->save(); + unset($data['trialConfiguration']); + } + + return parent::handleRecordUpdate($record, $data); } } diff --git a/app/Filament/Resources/PlanTierResource.php b/app/Filament/Resources/PlanTierResource.php new file mode 100644 index 0000000..592aa46 --- /dev/null +++ b/app/Filament/Resources/PlanTierResource.php @@ -0,0 +1,185 @@ +schema([ + Grid::make(2)->schema([ + Forms\Components\TextInput::make('name') + ->label('Tier Name') + ->required() + ->maxLength(255) + ->helperText('e.g., Basic, Pro, Enterprise'), + + Forms\Components\TextInput::make('sort_order') + ->label('Sort Order') + ->numeric() + ->default(0) + ->helperText('Display order in hierarchy'), + ]), + + Forms\Components\Textarea::make('description') + ->label('Description') + ->rows(3) + ->maxLength(500) + ->helperText('Describe this tier level'), + + Forms\Components\Select::make('parent_tier_id') + ->label('Parent Tier') + ->relationship('parentTier', 'name') + ->nullable() + ->searchable() + ->helperText('Optional parent tier for hierarchical structure'), + + Forms\Components\KeyValue::make('metadata') + ->label('Metadata') + ->keyLabel('Key') + ->valueLabel('Value') + ->reorderable() + ->addable() + ->deletable() + ->helperText('Additional tier metadata'), + ]); + } + + public static function table(Table $table): Table + { + return $table + ->columns([ + Tables\Columns\TextColumn::make('name') + ->label('Tier Name') + ->searchable() + ->sortable(), + + Tables\Columns\TextColumn::make('parentTier.name') + ->label('Parent Tier') + ->badge() + ->color('info') + ->placeholder('Root Tier') + ->sortable(), + + Tables\Columns\TextColumn::make('description') + ->label('Description') + ->limit(50) + ->searchable(), + + Tables\Columns\TextColumn::make('sort_order') + ->label('Order') + ->sortable() + ->alignCenter(), + + Tables\Columns\TextColumn::make('plans_count') + ->label('Plans') + ->counts('plans') + ->badge() + ->color('primary') + ->sortable(false), + ]) + ->filters([ + Tables\Filters\SelectFilter::make('parent_tier_id') + ->label('Parent Tier') + ->relationship('parentTier', 'name') + ->searchable() + ->placeholder('All tiers'), + + Tables\Filters\Filter::make('has_parent') + ->label('Has Parent') + ->query(fn (Builder $query): Builder => $query->whereNotNull('parent_tier_id')) + ->toggle(), + + Tables\Filters\Filter::make('is_root') + ->label('Root Tiers Only') + ->query(fn (Builder $query): Builder => $query->whereNull('parent_tier_id')) + ->toggle(), + ]) + ->recordActions([ + EditAction::make(), + DeleteAction::make() + ->before(function (PlanTier $record) { + // Prevent deletion if tier has child tiers or plans + if ($record->childTiers()->exists()) { + Log::error('Cannot delete tier that has child tiers'); + } + if ($record->plans()->exists()) { + Log::error('Cannot delete tier that has plans assigned'); + } + }), + ]) + ->toolbarActions([ + BulkActionGroup::make([ + DeleteBulkAction::make() + ->before(function ($records) { + foreach ($records as $record) { + if ($record->childTiers()->exists()) { + Log::error('Cannot delete tiers that have child tiers'); + } + if ($record->plans()->exists()) { + Log::error('Cannot delete tiers that have plans assigned'); + } + } + }), + ]), + ]) + ->emptyStateActions([ + CreateAction::make(), + ]) + ->defaultSort('sort_order', 'asc'); + } + + public static function getRelations(): array + { + return [ + // + ]; + } + + public static function getPages(): array + { + return [ + 'index' => ListPlanTiers::route('/'), + 'create' => CreatePlanTier::route('/create'), + 'edit' => EditPlanTier::route('/{record}/edit'), + ]; + } + + public static function getNavigationBadge(): ?string + { + return static::getModel()::count(); + } + + public static function getNavigationBadgeColor(): ?string + { + return 'info'; + } +} diff --git a/app/Filament/Resources/PlanTierResource/Pages/CreatePlanTier.php b/app/Filament/Resources/PlanTierResource/Pages/CreatePlanTier.php new file mode 100644 index 0000000..40a4328 --- /dev/null +++ b/app/Filament/Resources/PlanTierResource/Pages/CreatePlanTier.php @@ -0,0 +1,16 @@ +getResource()::getUrl('index'); + } +} diff --git a/app/Filament/Resources/PlanTierResource/Pages/EditPlanTier.php b/app/Filament/Resources/PlanTierResource/Pages/EditPlanTier.php new file mode 100644 index 0000000..e4753e1 --- /dev/null +++ b/app/Filament/Resources/PlanTierResource/Pages/EditPlanTier.php @@ -0,0 +1,43 @@ +before(function (PlanTier $record) { + // Prevent deletion if tier has child tiers or plans + if ($record->childTiers()->exists()) { + throw new \Exception('Cannot delete tier that has child tiers'); + } + if ($record->plans()->exists()) { + throw new \Exception('Cannot delete tier that has plans assigned'); + } + }), + ]; + } + + protected function getRedirectUrl(): string + { + return $this->getResource()::getUrl('index'); + } + + protected function getSavedNotification(): ?Notification + { + return Notification::make() + ->success() + ->title('Tier updated') + ->body('Plan tier updated successfully'); + } +} diff --git a/app/Filament/Resources/PlanTierResource/Pages/ListPlanTiers.php b/app/Filament/Resources/PlanTierResource/Pages/ListPlanTiers.php new file mode 100644 index 0000000..a65e6b4 --- /dev/null +++ b/app/Filament/Resources/PlanTierResource/Pages/ListPlanTiers.php @@ -0,0 +1,19 @@ + 'boolean', 'accept_shoppy' => 'boolean', 'accept_oxapay' => 'boolean', + 'is_active' => 'boolean', + 'metadata' => 'array', ]; + + /** + * Get the plan tier that this plan belongs to + */ + public function planTier(): BelongsTo + { + return $this->belongsTo(PlanTier::class); + } + + /** + * Get feature limits for this plan + */ + public function planFeatureLimits(): HasMany + { + return $this->hasMany(PlanFeatureLimit::class); + } + + /** + * Get permissions for this plan + */ + public function planPermissions(): HasMany + { + return $this->hasMany(PlanPermission::class); + } + + /** + * Get payment providers for this plan + */ + public function planProviders(): HasMany + { + return $this->hasMany(PlanProvider::class); + } + + /** + * Get trial configuration for this plan + */ + public function trialConfiguration(): HasOne + { + return $this->hasOne(TrialConfiguration::class); + } + + /** + * Get subscriptions for this plan + */ + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + /** + * Get usage tracking for this plan + */ + public function planUsages(): HasMany + { + return $this->hasMany(PlanUsage::class); + } + + /** + * Get features associated with this plan (through limits) + */ + public function features(): BelongsToMany + { + return $this->belongsToMany(PlanFeature::class, 'plan_feature_limits') + ->withPivot(['limit_value', 'is_enabled', 'limit_type', 'applies_during_trial', 'trial_limit_value']) + ->withTimestamps(); + } + + /** + * Scope: Active plans + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Ordered by sort order + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + /** + * Scope: By tier + */ + public function scopeByTier($query, $tierId) + { + return $query->where('plan_tier_id', $tierId); + } + + /** + * Check if plan has a specific feature enabled + */ + public function hasFeature(string $featureName): bool + { + $featureLimit = $this->planFeatureLimits() + ->whereHas('planFeature', function ($query) use ($featureName) { + $query->where('name', $featureName); + }) + ->first(); + + return $featureLimit && $featureLimit->isEnabled(); + } + + /** + * Check if user can use a feature within limits + */ + public function canUseFeature(string $featureName, float $currentUsage = 0, bool $isOnTrial = false): bool + { + $featureLimit = $this->planFeatureLimits() + ->whereHas('planFeature', function ($query) use ($featureName) { + $query->where('name', $featureName); + }) + ->first(); + + return $featureLimit ? $featureLimit->canUseFeature($currentUsage, $isOnTrial) : false; + } + + /** + * Get remaining usage for a feature + */ + public function getRemainingUsage(string $featureName, float $currentUsage = 0, bool $isOnTrial = false): float + { + $featureLimit = $this->planFeatureLimits() + ->whereHas('planFeature', function ($query) use ($featureName) { + $query->where('name', $featureName); + }) + ->first(); + + return $featureLimit ? $featureLimit->getRemainingUsage($currentUsage, $isOnTrial) : 0; + } + + /** + * Check if plan has a specific permission + */ + public function hasPermission(string $featureName, string $permission, bool $isOnTrial = false): bool + { + $planPermission = $this->planPermissions() + ->whereHas('planFeature', function ($query) use ($featureName) { + $query->where('name', $featureName); + }) + ->where('permission', $permission) + ->first(); + + return $planPermission ? $planPermission->isEffectivePermission($isOnTrial) : false; + } + + /** + * Get allowed payment providers for this plan + */ + public function getAllowedProviders(): array + { + return $this->planProviders() + ->enabled() + ->orderBy('sort_order') + ->pluck('provider') + ->toArray(); + } + + /** + * Check if plan supports a specific payment provider + */ + public function supportsProvider(string $provider): bool + { + return $this->planProviders() + ->where('provider', $provider) + ->where('is_enabled', true) + ->exists(); + } + + /** + * Get provider configuration for a specific provider + */ + public function getProviderConfig(string $provider): ?PlanProvider + { + return $this->planProviders() + ->where('provider', $provider) + ->where('is_enabled', true) + ->first(); + } + + /** + * Check if plan has trial enabled + */ + public function hasTrial(): bool + { + return $this->trialConfiguration && $this->trialConfiguration->trial_enabled; + } + + /** + * Get trial configuration + */ + public function getTrialConfig(): ?TrialConfiguration + { + return $this->trialConfiguration?->trial_enabled ? $this->trialConfiguration : null; + } + + /** + * Get billing cycle in human readable format + */ + public function getBillingCycleDisplay(): string + { + if ($this->billing_cycle_days) { + if ($this->billing_cycle_days == 30) { + return 'Monthly'; + } + if ($this->billing_cycle_days == 90) { + return 'Quarterly'; + } + if ($this->billing_cycle_days == 365) { + return 'Yearly'; + } + + return "{$this->billing_cycle_days} days"; + } + + return $this->monthly_billing ? 'Monthly' : 'Yearly'; + } + + /** + * Get plan metadata value + */ + public function getMetadata(?string $key = null, $default = null) + { + if ($key) { + return data_get($this->metadata, $key, $default); + } + + return $this->metadata; + } + + /** + * Set plan metadata value + */ + public function setMetadata(string $key, $value): void + { + $data = $this->metadata ?? []; + data_set($data, $key, $value); + $this->metadata = $data; + } + + /** + * Get all features with their limits and permissions + */ + public function getFeaturesWithLimits(): array + { + return $this->planFeatureLimits() + ->with('planFeature') + ->get() + ->map(function ($limit) { + return [ + 'feature' => $limit->planFeature, + 'limit' => $limit, + 'permissions' => $this->planPermissions() + ->where('plan_feature_id', $limit->plan_feature_id) + ->get(), + ]; + }) + ->toArray(); + } + + /** + * Check if this plan is an upgrade from another plan + */ + public function isUpgradeFrom(Plan $otherPlan): bool + { + // Simple logic: check if this plan has higher tier or price + if ($this->planTier_id && $otherPlan->planTier_id) { + return $this->planTier->sort_order > $otherPlan->planTier->sort_order; + } + + return $this->price > $otherPlan->price; + } + + /** + * Get upgrade path to this plan + */ + public function getUpgradePath(): array + { + // Return plans that can be upgraded to this plan + return Plan::where('id', '!=', $this->id) + ->where(function ($query) { + $query->where('price', '<', $this->price) + ->orWhereHas('planTier', function ($q) { + $q->where('sort_order', '<', $this->planTier?->sort_order ?? 0); + }); + }) + ->active() + ->ordered() + ->get() + ->toArray(); + } } diff --git a/app/Models/PlanFeature.php b/app/Models/PlanFeature.php new file mode 100644 index 0000000..d2553f7 --- /dev/null +++ b/app/Models/PlanFeature.php @@ -0,0 +1,116 @@ + 'array', + 'is_active' => 'boolean', + ]; + + /** + * Feature categories + */ + const CATEGORY_CORE = 'core'; + + const CATEGORY_ADVANCED = 'advanced'; + + const CATEGORY_PREMIUM = 'premium'; + + /** + * Feature types + */ + const TYPE_BOOLEAN = 'boolean'; + + const TYPE_NUMERIC = 'numeric'; + + const TYPE_TOGGLE = 'toggle'; + + /** + * Get plan feature limits for this feature + */ + public function planFeatureLimits(): HasMany + { + return $this->hasMany(PlanFeatureLimit::class); + } + + /** + * Get plan permissions for this feature + */ + public function planPermissions(): HasMany + { + return $this->hasMany(PlanPermission::class); + } + + /** + * Get usage tracking for this feature + */ + public function planUsages(): HasMany + { + return $this->hasMany(PlanUsage::class); + } + + /** + * Scope: Active features + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: By category + */ + public function scopeByCategory($query, string $category) + { + return $query->where('category', $category); + } + + /** + * Scope: Ordered by sort order + */ + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('display_name'); + } + + /** + * Get feature metadata value + */ + public function getMetadata(?string $key = null, $default = null) + { + if ($key) { + return data_get($this->metadata, $key, $default); + } + + return $this->metadata; + } + + /** + * Set feature metadata value + */ + public function setMetadata(string $key, $value): void + { + $data = $this->metadata ?? []; + data_set($data, $key, $value); + $this->metadata = $data; + } +} diff --git a/app/Models/PlanFeatureLimit.php b/app/Models/PlanFeatureLimit.php new file mode 100644 index 0000000..adee01c --- /dev/null +++ b/app/Models/PlanFeatureLimit.php @@ -0,0 +1,139 @@ + 'decimal:2', + 'trial_limit_value' => 'decimal:2', + 'is_enabled' => 'boolean', + 'applies_during_trial' => 'boolean', + 'metadata' => 'array', + ]; + + /** + * Limit types + */ + const LIMIT_MONTHLY = 'monthly'; + + const LIMIT_DAILY = 'daily'; + + const LIMIT_TOTAL = 'total'; + + /** + * Get the plan that owns this feature limit + */ + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + /** + * Get the feature that this limit applies to + */ + public function planFeature(): BelongsTo + { + return $this->belongsTo(PlanFeature::class); + } + + /** + * Scope: Enabled limits + */ + public function scopeEnabled($query) + { + return $query->where('is_enabled', true); + } + + /** + * Scope: By limit type + */ + public function scopeByType($query, string $type) + { + return $query->where('limit_type', $type); + } + + /** + * Check if feature is enabled for this plan + */ + public function isEnabled(): bool + { + return $this->is_enabled; + } + + /** + * Get the effective limit value (considering trial status) + */ + public function getEffectiveLimit(bool $isOnTrial = false): ?float + { + if ($isOnTrial && $this->applies_during_trial && $this->trial_limit_value !== null) { + return (float) $this->trial_limit_value; + } + + return $this->limit_value ? (float) $this->limit_value : null; + } + + /** + * Check if user can use this feature within limits + */ + public function canUseFeature(float $currentUsage = 0, bool $isOnTrial = false): bool + { + if (! $this->isEnabled()) { + return false; + } + + $limit = $this->getEffectiveLimit($isOnTrial); + + // If limit is null, feature is unlimited + if ($limit === null) { + return true; + } + + return $currentUsage < $limit; + } + + /** + * Get remaining usage allowance + */ + public function getRemainingUsage(float $currentUsage = 0, bool $isOnTrial = false): float + { + $limit = $this->getEffectiveLimit($isOnTrial); + + if ($limit === null) { + return INF; + } + + return max(0, $limit - $currentUsage); + } + + /** + * Get percentage of limit used + */ + public function getUsagePercentage(float $currentUsage = 0, bool $isOnTrial = false): float + { + $limit = $this->getEffectiveLimit($isOnTrial); + + if ($limit === null || $limit == 0) { + return 0; + } + + return min(100, ($currentUsage / $limit) * 100); + } +} diff --git a/app/Models/PlanPermission.php b/app/Models/PlanPermission.php new file mode 100644 index 0000000..914872a --- /dev/null +++ b/app/Models/PlanPermission.php @@ -0,0 +1,53 @@ + 'boolean', + 'applies_during_trial' => 'boolean', + 'trial_permission_override' => 'boolean', + 'conditions' => 'array', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function planFeature(): BelongsTo + { + return $this->belongsTo(PlanFeature::class); + } + + public function scopeGranted($query) + { + return $query->where('is_granted', true); + } + + public function isEffectivePermission(bool $isOnTrial = false): bool + { + if ($isOnTrial && $this->applies_during_trial && $this->trial_permission_override !== null) { + return $this->trial_permission_override; + } + + return $this->is_granted; + } +} diff --git a/app/Models/PlanProvider.php b/app/Models/PlanProvider.php new file mode 100644 index 0000000..92b64c8 --- /dev/null +++ b/app/Models/PlanProvider.php @@ -0,0 +1,55 @@ + 'boolean', + 'price' => 'decimal:2', + 'provider_data' => 'array', + ]; + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function scopeEnabled($query) + { + return $query->where('is_enabled', true); + } + + public function scopeByProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + public function getProviderData(?string $key = null, $default = null) + { + if ($key) { + return data_get($this->provider_data, $key, $default); + } + + return $this->provider_data; + } +} diff --git a/app/Models/PlanTier.php b/app/Models/PlanTier.php new file mode 100644 index 0000000..1809847 --- /dev/null +++ b/app/Models/PlanTier.php @@ -0,0 +1,54 @@ + 'array', + ]; + + public function plans(): HasMany + { + return $this->hasMany(Plan::class); + } + + public function parentTier(): BelongsTo + { + return $this->belongsTo(PlanTier::class, 'parent_tier_id'); + } + + public function childTiers(): HasMany + { + return $this->hasMany(PlanTier::class, 'parent_tier_id'); + } + + public function scopeOrdered($query) + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + public function getMetadata(?string $key = null, $default = null) + { + if ($key) { + return data_get($this->metadata, $key, $default); + } + + return $this->metadata; + } +} diff --git a/app/Models/PlanUsage.php b/app/Models/PlanUsage.php new file mode 100644 index 0000000..791c052 --- /dev/null +++ b/app/Models/PlanUsage.php @@ -0,0 +1,71 @@ + 'decimal:2', + 'period_start' => 'date', + 'period_end' => 'date', + 'metadata' => 'array', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function planFeature(): BelongsTo + { + return $this->belongsTo(PlanFeature::class); + } + + public function scopeByUser($query, $userId) + { + return $query->where('user_id', $userId); + } + + public function scopeByPeriod($query, $startDate, $endDate) + { + return $query->where('period_start', $startDate) + ->where('period_end', $endDate); + } + + public function scopeMonthly($query) + { + return $query->where('usage_type', 'monthly'); + } + + public function incrementUsage(float $amount = 1): void + { + $this->increment('usage_amount', $amount); + } + + public function resetUsage(): void + { + $this->update(['usage_amount' => 0]); + } +} diff --git a/app/Models/TrialConfiguration.php b/app/Models/TrialConfiguration.php new file mode 100644 index 0000000..d59cdf2 --- /dev/null +++ b/app/Models/TrialConfiguration.php @@ -0,0 +1,71 @@ + 'boolean', + 'trial_duration_days' => 'integer', + 'trial_requires_payment_method' => 'boolean', + 'trial_auto_converts' => 'boolean', + 'trial_conversion_action' => 'string', + 'trial_extension_limit' => 'integer', + 'trial_feature_overrides' => 'array', + ]; + + public const ACTION_UPGRADE_TO_PAID = 'upgrade_to_paid'; + + public const ACTION_CANCEL = 'cancel'; + + public const ACTION_NOTIFY = 'notify'; + + public function plan(): BelongsTo + { + return $this->belongsTo(Plan::class); + } + + public function scopeEnabled($query) + { + return $query->where('trial_enabled', true); + } + + public function canExtendTrial(int $currentExtensions = 0): bool + { + return $this->trial_extension_limit > $currentExtensions; + } + + public function getTrialEndDate(): \Carbon\Carbon + { + return now()->addDays($this->trial_duration_days); + } + + public function hasFeatureOverride(string $featureName): bool + { + return isset($this->trial_feature_overrides[$featureName]); + } + + public function getFeatureOverride(string $featureName) + { + return $this->trial_feature_overrides[$featureName] ?? null; + } +} diff --git a/database/migrations/2025_11_20_192916_create_plan_features_table.php b/database/migrations/2025_11_20_192916_create_plan_features_table.php new file mode 100644 index 0000000..0442fbe --- /dev/null +++ b/database/migrations/2025_11_20_192916_create_plan_features_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('name'); // e.g., 'email_forwarding', 'auto_reply_rules' + $table->string('display_name'); // e.g., 'Email Forwarding', 'Auto-Reply Rules' + $table->text('description')->nullable(); + $table->string('category'); // e.g., 'core', 'advanced', 'premium' + $table->string('type'); // e.g., 'boolean', 'numeric', 'toggle' + $table->json('metadata')->nullable(); // Additional feature-specific data + $table->boolean('is_active')->default(true); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['name']); + $table->index(['category', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_features'); + } +}; diff --git a/database/migrations/2025_11_20_193045_create_plan_feature_limits_table.php b/database/migrations/2025_11_20_193045_create_plan_feature_limits_table.php new file mode 100644 index 0000000..6bcd0f8 --- /dev/null +++ b/database/migrations/2025_11_20_193045_create_plan_feature_limits_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('plan_id')->constrained()->onDelete('cascade'); + $table->foreignId('plan_feature_id')->constrained()->onDelete('cascade'); + $table->decimal('limit_value', 10, 2)->nullable(); // Numeric limit (e.g., 1000 emails) + $table->boolean('is_enabled')->default(true); // Feature is enabled/disabled + $table->string('limit_type')->default('monthly'); // monthly, daily, total + $table->json('metadata')->nullable(); // Additional limit-specific data + $table->boolean('applies_during_trial')->default(true); // Whether limit applies during trial + $table->decimal('trial_limit_value', 10, 2)->nullable(); // Different limit for trial period + $table->timestamps(); + + $table->unique(['plan_id', 'plan_feature_id']); + $table->index(['plan_id', 'is_enabled']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_feature_limits'); + } +}; diff --git a/database/migrations/2025_11_20_193231_create_plan_permissions_table.php b/database/migrations/2025_11_20_193231_create_plan_permissions_table.php new file mode 100644 index 0000000..adbe577 --- /dev/null +++ b/database/migrations/2025_11_20_193231_create_plan_permissions_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('plan_id')->constrained()->onDelete('cascade'); + $table->foreignId('plan_feature_id')->constrained()->onDelete('cascade'); + $table->string('permission'); // e.g., 'can_export', 'can_use_api' + $table->boolean('is_granted')->default(true); + $table->json('conditions')->nullable(); // Additional conditions for permission + $table->boolean('applies_during_trial')->default(true); + $table->boolean('trial_permission_override')->nullable(); // Different trial permission + $table->timestamps(); + + $table->unique(['plan_id', 'plan_feature_id', 'permission']); + $table->index(['plan_id', 'is_granted']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_permissions'); + } +}; diff --git a/database/migrations/2025_11_20_193232_create_plan_providers_table.php b/database/migrations/2025_11_20_193232_create_plan_providers_table.php new file mode 100644 index 0000000..0c9e9f5 --- /dev/null +++ b/database/migrations/2025_11_20_193232_create_plan_providers_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('plan_id')->constrained()->onDelete('cascade'); + $table->string('provider'); // stripe, lemon_squeezy, polar, etc. + $table->string('provider_price_id')->nullable(); // Price ID from provider + $table->string('provider_variant_id')->nullable(); // Variant ID from provider + $table->string('provider_product_id')->nullable(); // Product ID from provider + $table->boolean('is_enabled')->default(true); + $table->decimal('price', 10, 2)->nullable(); + $table->string('currency', 3)->default('USD'); + $table->json('provider_data')->nullable(); // Provider-specific configuration + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['plan_id', 'provider']); + $table->index(['plan_id', 'is_enabled']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_providers'); + } +}; diff --git a/database/migrations/2025_11_20_193233_create_plan_tiers_table.php b/database/migrations/2025_11_20_193233_create_plan_tiers_table.php new file mode 100644 index 0000000..0f6ccc5 --- /dev/null +++ b/database/migrations/2025_11_20_193233_create_plan_tiers_table.php @@ -0,0 +1,35 @@ +id(); + $table->string('name'); // Basic, Pro, Enterprise + $table->text('description')->nullable(); + $table->foreignId('parent_tier_id')->nullable()->constrained('plan_tiers')->onDelete('set null'); + $table->integer('sort_order')->default(0); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->index(['parent_tier_id']); + $table->index(['sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_tiers'); + } +}; diff --git a/database/migrations/2025_11_20_193234_create_plan_usages_table.php b/database/migrations/2025_11_20_193234_create_plan_usages_table.php new file mode 100644 index 0000000..19e7e3d --- /dev/null +++ b/database/migrations/2025_11_20_193234_create_plan_usages_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->foreignId('plan_id')->constrained()->onDelete('cascade'); + $table->foreignId('plan_feature_id')->constrained()->onDelete('cascade'); + $table->decimal('usage_amount', 10, 2)->default(0); + $table->string('usage_type')->default('monthly'); // monthly, daily, total + $table->date('period_start'); // Usage period start date + $table->date('period_end'); // Usage period end date + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'plan_feature_id', 'usage_type', 'period_start']); + $table->index(['user_id', 'period_start']); + $table->index(['plan_feature_id', 'period_start']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plan_usages'); + } +}; diff --git a/database/migrations/2025_11_20_193235_create_trial_configurations_table.php b/database/migrations/2025_11_20_193235_create_trial_configurations_table.php new file mode 100644 index 0000000..f515c85 --- /dev/null +++ b/database/migrations/2025_11_20_193235_create_trial_configurations_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('plan_id')->constrained()->onDelete('cascade'); + $table->boolean('trial_enabled')->default(false); + $table->integer('trial_duration_days')->default(14); + $table->boolean('trial_requires_payment_method')->default(true); + $table->boolean('trial_auto_converts')->default(true); + $table->string('trial_conversion_action')->default('upgrade_to_paid'); // upgrade_to_paid, cancel, notify + $table->integer('trial_extension_limit')->default(0); // Max extensions allowed + $table->json('trial_feature_overrides')->nullable(); // Features to limit during trial + $table->text('trial_welcome_message')->nullable(); + $table->text('trial_expiry_message')->nullable(); + $table->timestamps(); + + $table->unique(['plan_id']); + $table->index(['trial_enabled']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('trial_configurations'); + } +}; diff --git a/database/migrations/2025_11_20_193831_add_enhanced_plan_fields_to_plans_table.php b/database/migrations/2025_11_20_193831_add_enhanced_plan_fields_to_plans_table.php new file mode 100644 index 0000000..f8675a4 --- /dev/null +++ b/database/migrations/2025_11_20_193831_add_enhanced_plan_fields_to_plans_table.php @@ -0,0 +1,42 @@ +foreignId('plan_tier_id')->nullable()->after('details')->constrained()->onDelete('set null'); + $table->integer('billing_cycle_days')->nullable()->after('plan_tier_id'); // 30, 90, 365, custom + $table->boolean('is_active')->default(true)->after('billing_cycle_days'); + $table->integer('sort_order')->default(0)->after('is_active'); + $table->json('metadata')->nullable()->after('sort_order'); + + $table->index(['is_active', 'sort_order']); + $table->index(['plan_tier_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropForeign(['plan_tier_id']); + $table->dropColumn([ + 'plan_tier_id', + 'billing_cycle_days', + 'is_active', + 'sort_order', + 'metadata', + ]); + }); + } +}; diff --git a/database/migrations/2025_11_21_153729_migrate_legacy_plans_to_enhanced_system.php b/database/migrations/2025_11_21_153729_migrate_legacy_plans_to_enhanced_system.php new file mode 100644 index 0000000..4ad4f79 --- /dev/null +++ b/database/migrations/2025_11_21_153729_migrate_legacy_plans_to_enhanced_system.php @@ -0,0 +1,362 @@ +createDefaultPlanFeatures(); + + // Migrate existing plans to new system + $this->migrateExistingPlans(); + + // Update legacy plan structure + $this->updateLegacyPlanStructure(); + } + + /** + * Create default plan features that match legacy functionality + */ + private function createDefaultPlanFeatures(): void + { + $features = [ + [ + 'name' => 'mailbox_accounts', + 'display_name' => 'Mailbox Accounts', + 'description' => 'Number of mailbox accounts allowed', + 'category' => 'core', + 'type' => 'numeric', + 'is_active' => true, + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'advanced_filters', + 'display_name' => 'Advanced Filters', + 'description' => 'Advanced email filtering and rules', + 'category' => 'advanced', + 'type' => 'boolean', + 'is_active' => true, + 'sort_order' => 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'api_access', + 'display_name' => 'API Access', + 'description' => 'Access to developer API', + 'category' => 'advanced', + 'type' => 'boolean', + 'is_active' => true, + 'sort_order' => 11, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'name' => 'priority_support', + 'display_name' => 'Priority Support', + 'description' => 'Priority customer support', + 'category' => 'premium', + 'type' => 'boolean', + 'is_active' => true, + 'sort_order' => 20, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]; + + // Use insertOrIgnore to avoid conflicts with existing features + foreach ($features as $feature) { + DB::table('plan_features')->insertOrIgnore([$feature]); + } + + // Ensure email_forwarding exists with correct sort order + DB::table('plan_features') + ->where('name', 'email_forwarding') + ->update([ + 'sort_order' => 2, + 'updated_at' => now(), + ]); + } + + /** + * Migrate existing plans to use the new feature limits system + */ + private function migrateExistingPlans(): void + { + $plans = DB::table('plans')->get(); + + foreach ($plans as $plan) { + // Get feature IDs for mapping + $mailboxFeature = DB::table('plan_features')->where('name', 'mailbox_accounts')->first(); + $emailForwardingFeature = DB::table('plan_features')->where('name', 'email_forwarding')->first(); + $advancedFiltersFeature = DB::table('plan_features')->where('name', 'advanced_filters')->first(); + $apiAccessFeature = DB::table('plan_features')->where('name', 'api_access')->first(); + $prioritySupportFeature = DB::table('plan_features')->where('name', 'priority_support')->first(); + + // Create feature limits for each plan (only if they don't exist) + $existingLimitIds = DB::table('plan_feature_limits') + ->where('plan_id', $plan->id) + ->pluck('plan_feature_id') + ->toArray(); + + $featureLimits = []; + + // Mailbox limit (from legacy mailbox_limit) - only if not already exists + if ($mailboxFeature && ! in_array($mailboxFeature->id, $existingLimitIds)) { + $featureLimits[] = [ + 'plan_id' => $plan->id, + 'plan_feature_id' => $mailboxFeature->id, + 'limit_type' => 'total', + 'limit_value' => $plan->mailbox_limit, + 'is_enabled' => true, + 'applies_during_trial' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // Email forwarding (enabled for all plans) - only if not already exists + if ($emailForwardingFeature && ! in_array($emailForwardingFeature->id, $existingLimitIds)) { + $featureLimits[] = [ + 'plan_id' => $plan->id, + 'plan_feature_id' => $emailForwardingFeature->id, + 'limit_type' => 'boolean', + 'limit_value' => 1, // true + 'is_enabled' => true, + 'applies_during_trial' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // Advanced features based on plan tier/price + $isPremiumPlan = $plan->price >= 20; + + if ($advancedFiltersFeature && ! in_array($advancedFiltersFeature->id, $existingLimitIds)) { + $featureLimits[] = [ + 'plan_id' => $plan->id, + 'plan_feature_id' => $advancedFiltersFeature->id, + 'limit_type' => 'boolean', + 'limit_value' => $isPremiumPlan ? 1 : 0, + 'is_enabled' => true, + 'applies_during_trial' => false, // Not available during trial for advanced features + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if ($apiAccessFeature && ! in_array($apiAccessFeature->id, $existingLimitIds)) { + $featureLimits[] = [ + 'plan_id' => $plan->id, + 'plan_feature_id' => $apiAccessFeature->id, + 'limit_type' => 'boolean', + 'limit_value' => $isPremiumPlan ? 1 : 0, + 'is_enabled' => true, + 'applies_during_trial' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if ($prioritySupportFeature && ! in_array($prioritySupportFeature->id, $existingLimitIds)) { + $featureLimits[] = [ + 'plan_id' => $plan->id, + 'plan_feature_id' => $prioritySupportFeature->id, + 'limit_type' => 'boolean', + 'limit_value' => $plan->price >= 25 ? 1 : 0, + 'is_enabled' => true, + 'applies_during_trial' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // Insert only new feature limits + if (! empty($featureLimits)) { + DB::table('plan_feature_limits')->insert($featureLimits); + } + + // Create plan provider entries based on legacy accept_* columns + $this->createPlanProviders($plan); + + // Create trial configuration (disabled for legacy plans) + $this->createTrialConfiguration($plan); + } + } + + /** + * Create plan provider entries based on legacy provider acceptance columns + */ + private function createPlanProviders($plan): void + { + // Check if providers already exist for this plan + $existingProviders = DB::table('plan_providers') + ->where('plan_id', $plan->id) + ->exists(); + + if ($existingProviders) { + return; // Skip if providers already exist + } + + $providers = []; + + // Stripe + if ($plan->accept_stripe) { + $providers[] = [ + 'plan_id' => $plan->id, + 'provider' => 'stripe', + 'provider_price_id' => $plan->pricing_id, + 'price' => $plan->price, + 'currency' => 'USD', + 'is_enabled' => true, + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // OxaPay + if ($plan->accept_oxapay && $plan->oxapay_link) { + $providers[] = [ + 'plan_id' => $plan->id, + 'provider' => 'oxapay', + 'provider_price_id' => $plan->oxapay_link, + 'price' => $plan->price, + 'currency' => 'USD', + 'is_enabled' => true, + 'sort_order' => 5, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // Shoppy (if it exists) + if ($plan->accept_shoppy && $plan->shoppy_product_id) { + $providers[] = [ + 'plan_id' => $plan->id, + 'provider' => 'shoppy', + 'provider_price_id' => $plan->shoppy_product_id, + 'price' => $plan->price, + 'currency' => 'USD', + 'is_enabled' => true, + 'sort_order' => 3, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + // If no providers were explicitly enabled, enable Stripe by default + if (empty($providers) && $plan->pricing_id) { + $providers[] = [ + 'plan_id' => $plan->id, + 'provider' => 'stripe', + 'provider_price_id' => $plan->pricing_id, + 'price' => $plan->price, + 'currency' => 'USD', + 'is_enabled' => true, + 'sort_order' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]; + } + + if (! empty($providers)) { + DB::table('plan_providers')->insert($providers); + } + } + + /** + * Create trial configuration for legacy plans (disabled by default) + */ + private function createTrialConfiguration($plan): void + { + // Check if trial configuration already exists + $existingTrial = DB::table('trial_configurations') + ->where('plan_id', $plan->id) + ->exists(); + + if ($existingTrial) { + return; // Skip if trial config already exists + } + + DB::table('trial_configurations')->insert([ + 'plan_id' => $plan->id, + 'trial_enabled' => false, // Disabled for legacy plans + 'trial_duration_days' => 14, + 'trial_requires_payment_method' => true, + 'trial_auto_converts' => true, + 'trial_extension_limit' => 0, + 'trial_conversion_action' => 'upgrade_to_paid', + 'trial_welcome_message' => null, + 'trial_expiry_message' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + /** + * Update legacy plan structure - mark old columns as deprecated but keep them for rollback + */ + private function updateLegacyPlanStructure(): void + { + // Note: We're keeping the legacy columns for now to ensure rollback capability + // In a future migration, we can remove these columns: + // - mailbox_limit (replaced by plan_feature_limits) + // - monthly_billing (replaced by billing_cycle_days) + // - accept_stripe, accept_shoppy, accept_oxapay (replaced by plan_providers) + // - shoppy_product_id, oxapay_link (moved to plan_providers) + + // For now, just ensure billing_cycle_days is properly set based on monthly_billing + DB::statement(' + UPDATE plans + SET billing_cycle_days = CASE + WHEN monthly_billing = 1 THEN 30 + ELSE 365 + END + WHERE billing_cycle_days IS NULL OR billing_cycle_days = 0 + '); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Remove created plan features (except email_forwarding which existed before) + DB::table('plan_features')->whereIn('name', [ + 'mailbox_accounts', + 'advanced_filters', + 'api_access', + 'priority_support', + ])->delete(); + + // Remove feature limits, providers, and trial configurations for migrated plans + $planIds = DB::table('plans')->pluck('id'); + + DB::table('plan_feature_limits')->whereIn('plan_id', $planIds)->delete(); + DB::table('plan_providers')->whereIn('plan_id', $planIds)->delete(); + DB::table('trial_configurations')->whereIn('plan_id', $planIds)->delete(); + + // Restore mailbox_limit from feature limits if possible + $mailboxFeature = DB::table('plan_features')->where('name', 'mailbox_accounts')->first(); + if ($mailboxFeature) { + $limits = DB::table('plan_feature_limits') + ->where('plan_feature_id', $mailboxFeature->id) + ->get(); + + foreach ($limits as $limit) { + DB::table('plans') + ->where('id', $limit->plan_id) + ->update(['mailbox_limit' => $limit->limit_value]); + } + } + } +};