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

@@ -4,6 +4,10 @@ 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\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
class Plan extends Model
{
@@ -23,6 +27,12 @@ class Plan extends Model
'mailbox_limit',
'monthly_billing',
'details',
// New fields for enhanced plan system
'plan_tier_id',
'billing_cycle_days',
'is_active',
'sort_order',
'metadata',
];
protected $casts = [
@@ -31,5 +41,300 @@ class Plan extends Model
'accept_stripe' => 'boolean',
'accept_shoppy' => 'boolean',
'accept_oxapay' => 'boolean',
'is_active' => 'boolean',
'metadata' => 'array',
];
/**
* Get the plan tier that this plan belongs to
*/
public function planTier(): BelongsTo
{
return $this->belongsTo(PlanTier::class);
}
/**
* Get feature limits for this plan
*/
public function planFeatureLimits(): HasMany
{
return $this->hasMany(PlanFeatureLimit::class);
}
/**
* Get permissions for this plan
*/
public function planPermissions(): HasMany
{
return $this->hasMany(PlanPermission::class);
}
/**
* Get payment providers for this plan
*/
public function planProviders(): HasMany
{
return $this->hasMany(PlanProvider::class);
}
/**
* Get trial configuration for this plan
*/
public function trialConfiguration(): HasOne
{
return $this->hasOne(TrialConfiguration::class);
}
/**
* Get subscriptions for this plan
*/
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
/**
* Get usage tracking for this plan
*/
public function planUsages(): HasMany
{
return $this->hasMany(PlanUsage::class);
}
/**
* Get features associated with this plan (through limits)
*/
public function features(): BelongsToMany
{
return $this->belongsToMany(PlanFeature::class, 'plan_feature_limits')
->withPivot(['limit_value', 'is_enabled', 'limit_type', 'applies_during_trial', 'trial_limit_value'])
->withTimestamps();
}
/**
* Scope: Active plans
*/
public function scopeActive($query)
{
return $query->where('is_active', true);
}
/**
* Scope: Ordered by sort order
*/
public function scopeOrdered($query)
{
return $query->orderBy('sort_order')->orderBy('name');
}
/**
* Scope: By tier
*/
public function scopeByTier($query, $tierId)
{
return $query->where('plan_tier_id', $tierId);
}
/**
* Check if plan has a specific feature enabled
*/
public function hasFeature(string $featureName): bool
{
$featureLimit = $this->planFeatureLimits()
->whereHas('planFeature', function ($query) use ($featureName) {
$query->where('name', $featureName);
})
->first();
return $featureLimit && $featureLimit->isEnabled();
}
/**
* Check if user can use a feature within limits
*/
public function canUseFeature(string $featureName, float $currentUsage = 0, bool $isOnTrial = false): bool
{
$featureLimit = $this->planFeatureLimits()
->whereHas('planFeature', function ($query) use ($featureName) {
$query->where('name', $featureName);
})
->first();
return $featureLimit ? $featureLimit->canUseFeature($currentUsage, $isOnTrial) : false;
}
/**
* Get remaining usage for a feature
*/
public function getRemainingUsage(string $featureName, float $currentUsage = 0, bool $isOnTrial = false): float
{
$featureLimit = $this->planFeatureLimits()
->whereHas('planFeature', function ($query) use ($featureName) {
$query->where('name', $featureName);
})
->first();
return $featureLimit ? $featureLimit->getRemainingUsage($currentUsage, $isOnTrial) : 0;
}
/**
* Check if plan has a specific permission
*/
public function hasPermission(string $featureName, string $permission, bool $isOnTrial = false): bool
{
$planPermission = $this->planPermissions()
->whereHas('planFeature', function ($query) use ($featureName) {
$query->where('name', $featureName);
})
->where('permission', $permission)
->first();
return $planPermission ? $planPermission->isEffectivePermission($isOnTrial) : false;
}
/**
* Get allowed payment providers for this plan
*/
public function getAllowedProviders(): array
{
return $this->planProviders()
->enabled()
->orderBy('sort_order')
->pluck('provider')
->toArray();
}
/**
* Check if plan supports a specific payment provider
*/
public function supportsProvider(string $provider): bool
{
return $this->planProviders()
->where('provider', $provider)
->where('is_enabled', true)
->exists();
}
/**
* Get provider configuration for a specific provider
*/
public function getProviderConfig(string $provider): ?PlanProvider
{
return $this->planProviders()
->where('provider', $provider)
->where('is_enabled', true)
->first();
}
/**
* Check if plan has trial enabled
*/
public function hasTrial(): bool
{
return $this->trialConfiguration && $this->trialConfiguration->trial_enabled;
}
/**
* Get trial configuration
*/
public function getTrialConfig(): ?TrialConfiguration
{
return $this->trialConfiguration?->trial_enabled ? $this->trialConfiguration : null;
}
/**
* Get billing cycle in human readable format
*/
public function getBillingCycleDisplay(): string
{
if ($this->billing_cycle_days) {
if ($this->billing_cycle_days == 30) {
return 'Monthly';
}
if ($this->billing_cycle_days == 90) {
return 'Quarterly';
}
if ($this->billing_cycle_days == 365) {
return 'Yearly';
}
return "{$this->billing_cycle_days} days";
}
return $this->monthly_billing ? 'Monthly' : 'Yearly';
}
/**
* Get plan metadata value
*/
public function getMetadata(?string $key = null, $default = null)
{
if ($key) {
return data_get($this->metadata, $key, $default);
}
return $this->metadata;
}
/**
* Set plan metadata value
*/
public function setMetadata(string $key, $value): void
{
$data = $this->metadata ?? [];
data_set($data, $key, $value);
$this->metadata = $data;
}
/**
* Get all features with their limits and permissions
*/
public function getFeaturesWithLimits(): array
{
return $this->planFeatureLimits()
->with('planFeature')
->get()
->map(function ($limit) {
return [
'feature' => $limit->planFeature,
'limit' => $limit,
'permissions' => $this->planPermissions()
->where('plan_feature_id', $limit->plan_feature_id)
->get(),
];
})
->toArray();
}
/**
* Check if this plan is an upgrade from another plan
*/
public function isUpgradeFrom(Plan $otherPlan): bool
{
// Simple logic: check if this plan has higher tier or price
if ($this->planTier_id && $otherPlan->planTier_id) {
return $this->planTier->sort_order > $otherPlan->planTier->sort_order;
}
return $this->price > $otherPlan->price;
}
/**
* Get upgrade path to this plan
*/
public function getUpgradePath(): array
{
// Return plans that can be upgraded to this plan
return Plan::where('id', '!=', $this->id)
->where(function ($query) {
$query->where('price', '<', $this->price)
->orWhereHas('planTier', function ($q) {
$q->where('sort_order', '<', $this->planTier?->sort_order ?? 0);
});
})
->active()
->ordered()
->get()
->toArray();
}
}

116
app/Models/PlanFeature.php Normal file
View 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;
}
}

View 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);
}
}

View 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;
}
}

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

View 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;
}
}