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:
idevakk
2025-11-21 07:59:21 -08:00
parent 5f5da23a40
commit b497f7796d
27 changed files with 2664 additions and 76 deletions

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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',
]);
});
}
};

View File

@@ -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]);
}
}
}
};