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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanFeatureResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePlanFeature extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanFeatureResource::class;
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanFeatureResource;
|
||||||
|
use App\Models\PlanFeature;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditPlanFeature extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanFeatureResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanFeatureResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanFeatureResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPlanFeatures extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanFeatureResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,74 +2,126 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
use BackedEnum;
|
|
||||||
use Filament\Support\Icons\Heroicon;
|
|
||||||
use UnitEnum;
|
|
||||||
use App\Filament\Resources\PlanResource\Pages\CreatePlan;
|
use App\Filament\Resources\PlanResource\Pages\CreatePlan;
|
||||||
use App\Filament\Resources\PlanResource\Pages\EditPlan;
|
use App\Filament\Resources\PlanResource\Pages\EditPlan;
|
||||||
use App\Filament\Resources\PlanResource\Pages\ListPlans;
|
use App\Filament\Resources\PlanResource\Pages\ListPlans;
|
||||||
use App\Models\Plan;
|
use App\Models\Plan;
|
||||||
|
use App\Models\PlanTier;
|
||||||
|
use BackedEnum;
|
||||||
use Filament\Actions\BulkActionGroup;
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\DeleteBulkAction;
|
use Filament\Actions\DeleteBulkAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
use Filament\Actions\ViewAction;
|
use Filament\Actions\ViewAction;
|
||||||
use Filament\Forms\Components\KeyValue;
|
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\BooleanColumn;
|
use Filament\Support\Icons\Heroicon;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables;
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
class PlanResource extends Resource
|
class PlanResource extends Resource
|
||||||
{
|
{
|
||||||
protected static ?string $model = Plan::class;
|
protected static ?string $model = Plan::class;
|
||||||
|
|
||||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedInboxStack;
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCreditCard;
|
||||||
|
|
||||||
protected static string|UnitEnum|null $navigationGroup = 'Admin';
|
protected static string|UnitEnum|null $navigationGroup = 'Subscription Management';
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema
|
return $schema
|
||||||
->components([
|
->components([
|
||||||
Section::make('Plan Information')
|
Grid::make(3)->schema([
|
||||||
->description('Add a new plan')
|
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([
|
->schema([
|
||||||
TextInput::make('name')->label('Page Name')
|
Grid::make(3)->schema([
|
||||||
->required(),
|
Toggle::make('monthly_billing')
|
||||||
TextInput::make('description'),
|
->label('Monthly Billing (Legacy)')
|
||||||
TextInput::make('product_id')->required(),
|
->helperText('Legacy monthly billing flag'),
|
||||||
TextInput::make('pricing_id')->required(),
|
|
||||||
TextInput::make('shoppy_product_id')->nullable(),
|
TextInput::make('mailbox_limit')
|
||||||
TextInput::make('oxapay_link')->nullable(),
|
->label('Mailbox Limit')
|
||||||
TextInput::make('price')->numeric()->required(),
|
->numeric()
|
||||||
TextInput::make('mailbox_limit')->numeric()->required(),
|
->default(10)
|
||||||
Select::make('monthly_billing')->options([
|
->helperText('Maximum number of mailboxes'),
|
||||||
1 => 'Monthly',
|
|
||||||
0 => 'Yearly',
|
TextInput::make('shoppy_product_id')
|
||||||
])->required(),
|
->label('Shoppy Product ID')
|
||||||
Select::make('accept_stripe')->options([
|
->nullable(),
|
||||||
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(),
|
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -78,46 +130,108 @@ class PlanResource extends Resource
|
|||||||
{
|
{
|
||||||
return $table
|
return $table
|
||||||
->columns([
|
->columns([
|
||||||
TextColumn::make('name')->label('Name')->searchable(),
|
Tables\Columns\TextColumn::make('name')
|
||||||
TextColumn::make('product_id')->label('Product'),
|
->label('Plan Name')
|
||||||
TextColumn::make('pricing_id')->label('Pricing'),
|
->searchable()
|
||||||
TextColumn::make('price')->label('Price'),
|
->sortable(),
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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([
|
->recordActions([
|
||||||
ViewAction::make(),
|
ViewAction::make(),
|
||||||
EditAction::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([
|
->toolbarActions([
|
||||||
BulkActionGroup::make([
|
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'),
|
'edit' => EditPlan::route('/{record}/edit'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadge(): ?string
|
||||||
|
{
|
||||||
|
return static::getModel()::active()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getNavigationBadgeColor(): ?string
|
||||||
|
{
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,360 @@
|
|||||||
namespace App\Filament\Resources\PlanResource\Pages;
|
namespace App\Filament\Resources\PlanResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\PlanResource;
|
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\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
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
|
class EditPlan extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = PlanResource::class;
|
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
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [
|
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
|
protected function getRedirectUrl(): ?string
|
||||||
{
|
{
|
||||||
return $this->getResource()::getUrl('index');
|
return self::getResource()::getUrl('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getSavedNotification(): ?Notification
|
protected function getSavedNotification(): ?Notification
|
||||||
@@ -28,6 +364,26 @@ class EditPlan extends EditRecord
|
|||||||
return Notification::make()
|
return Notification::make()
|
||||||
->success()
|
->success()
|
||||||
->title('Plan updated')
|
->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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
185
app/Filament/Resources/PlanTierResource.php
Normal file
185
app/Filament/Resources/PlanTierResource.php
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanTierResource\Pages\CreatePlanTier;
|
||||||
|
use App\Filament\Resources\PlanTierResource\Pages\EditPlanTier;
|
||||||
|
use App\Filament\Resources\PlanTierResource\Pages\ListPlanTiers;
|
||||||
|
use App\Models\PlanTier;
|
||||||
|
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\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Grid;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PlanTierResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PlanTier::class;
|
||||||
|
|
||||||
|
protected static string|null|\BackedEnum $navigationIcon = Heroicon::OutlinedServerStack;
|
||||||
|
|
||||||
|
protected static string|null|\UnitEnum $navigationGroup = 'Subscription Management';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanTierResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreatePlanTier extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanTierResource::class;
|
||||||
|
|
||||||
|
protected function getRedirectUrl(): string
|
||||||
|
{
|
||||||
|
return $this->getResource()::getUrl('index');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanTierResource;
|
||||||
|
use App\Models\PlanTier;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditPlanTier extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanTierResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\DeleteAction::make()
|
||||||
|
->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PlanTierResource\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PlanTierResource;
|
||||||
|
use Filament\Actions;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPlanTiers extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanTierResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Actions\CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ namespace App\Models;
|
|||||||
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
class Plan extends Model
|
class Plan extends Model
|
||||||
{
|
{
|
||||||
@@ -23,6 +27,12 @@ class Plan extends Model
|
|||||||
'mailbox_limit',
|
'mailbox_limit',
|
||||||
'monthly_billing',
|
'monthly_billing',
|
||||||
'details',
|
'details',
|
||||||
|
// New fields for enhanced plan system
|
||||||
|
'plan_tier_id',
|
||||||
|
'billing_cycle_days',
|
||||||
|
'is_active',
|
||||||
|
'sort_order',
|
||||||
|
'metadata',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
@@ -31,5 +41,300 @@ class Plan extends Model
|
|||||||
'accept_stripe' => 'boolean',
|
'accept_stripe' => 'boolean',
|
||||||
'accept_shoppy' => 'boolean',
|
'accept_shoppy' => 'boolean',
|
||||||
'accept_oxapay' => '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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
116
app/Models/PlanFeature.php
Normal file
116
app/Models/PlanFeature.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PlanFeature extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'display_name',
|
||||||
|
'description',
|
||||||
|
'category',
|
||||||
|
'type',
|
||||||
|
'metadata',
|
||||||
|
'is_active',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
app/Models/PlanFeatureLimit.php
Normal file
139
app/Models/PlanFeatureLimit.php
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PlanFeatureLimit extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'plan_id',
|
||||||
|
'plan_feature_id',
|
||||||
|
'limit_value',
|
||||||
|
'is_enabled',
|
||||||
|
'limit_type',
|
||||||
|
'metadata',
|
||||||
|
'applies_during_trial',
|
||||||
|
'trial_limit_value',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'limit_value' => '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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Models/PlanPermission.php
Normal file
53
app/Models/PlanPermission.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PlanPermission extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'plan_id',
|
||||||
|
'plan_feature_id',
|
||||||
|
'permission',
|
||||||
|
'is_granted',
|
||||||
|
'conditions',
|
||||||
|
'applies_during_trial',
|
||||||
|
'trial_permission_override',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_granted' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
app/Models/PlanProvider.php
Normal file
55
app/Models/PlanProvider.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PlanProvider extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'plan_id',
|
||||||
|
'provider',
|
||||||
|
'provider_price_id',
|
||||||
|
'provider_variant_id',
|
||||||
|
'provider_product_id',
|
||||||
|
'is_enabled',
|
||||||
|
'price',
|
||||||
|
'currency',
|
||||||
|
'provider_data',
|
||||||
|
'sort_order',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_enabled' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Models/PlanTier.php
Normal file
54
app/Models/PlanTier.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class PlanTier extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'parent_tier_id',
|
||||||
|
'sort_order',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'metadata' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Models/PlanUsage.php
Normal file
71
app/Models/PlanUsage.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PlanUsage extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'plan_id',
|
||||||
|
'plan_feature_id',
|
||||||
|
'usage_amount',
|
||||||
|
'usage_type',
|
||||||
|
'period_start',
|
||||||
|
'period_end',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'usage_amount' => '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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Models/TrialConfiguration.php
Normal file
71
app/Models/TrialConfiguration.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class TrialConfiguration extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'plan_id',
|
||||||
|
'trial_enabled',
|
||||||
|
'trial_duration_days',
|
||||||
|
'trial_requires_payment_method',
|
||||||
|
'trial_auto_converts',
|
||||||
|
'trial_conversion_action',
|
||||||
|
'trial_extension_limit',
|
||||||
|
'trial_feature_overrides',
|
||||||
|
'trial_welcome_message',
|
||||||
|
'trial_expiry_message',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'trial_enabled' => '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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_features', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_feature_limits', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_permissions', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_providers', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_tiers', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plan_usages', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('trial_configurations', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('plans', function (Blueprint $table) {
|
||||||
|
$table->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Create default plan features for legacy functionality
|
||||||
|
$this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user