diff --git a/app/Filament/Resources/Plans/Schemas/PlanForm.php b/app/Filament/Resources/Plans/Schemas/PlanForm.php index a08fec9..591b2e1 100644 --- a/app/Filament/Resources/Plans/Schemas/PlanForm.php +++ b/app/Filament/Resources/Plans/Schemas/PlanForm.php @@ -16,7 +16,7 @@ class PlanForm ->components([ Section::make('Fuel Types') ->schema([ - TextInput::make('features.fuel_types.max') + TextInput::make('max_fuel_types') ->label('Max fuel types') ->helperText('Leave blank for unlimited.') ->numeric() @@ -28,9 +28,9 @@ class PlanForm Section::make('Email') ->columns(2) ->schema([ - Toggle::make('features.email.enabled') + Toggle::make('email_enabled') ->label('Enabled'), - Select::make('features.email.frequency') + Select::make('email_frequency') ->label('Frequency') ->options([ 'weekly_digest' => 'Weekly digest', @@ -42,9 +42,9 @@ class PlanForm Section::make('Push') ->columns(2) ->schema([ - Toggle::make('features.push.enabled') + Toggle::make('push_enabled') ->label('Enabled'), - Select::make('features.push.frequency') + Select::make('push_frequency') ->label('Frequency') ->options([ 'none' => 'None (disabled)', @@ -56,15 +56,15 @@ class PlanForm Section::make('WhatsApp') ->columns(3) ->schema([ - Toggle::make('features.whatsapp.enabled') + Toggle::make('whatsapp_enabled') ->label('Enabled'), - TextInput::make('features.whatsapp.daily_limit') + TextInput::make('whatsapp_daily_limit') ->label('Daily limit') ->numeric() ->integer() ->minValue(0) ->required(), - TextInput::make('features.whatsapp.scheduled_updates') + TextInput::make('whatsapp_scheduled_updates') ->label('Scheduled updates per day') ->numeric() ->integer() @@ -75,9 +75,9 @@ class PlanForm Section::make('SMS') ->columns(2) ->schema([ - Toggle::make('features.sms.enabled') + Toggle::make('sms_enabled') ->label('Enabled'), - TextInput::make('features.sms.daily_limit') + TextInput::make('sms_daily_limit') ->label('Daily limit') ->numeric() ->integer() @@ -87,11 +87,11 @@ class PlanForm Section::make('Features') ->schema([ - Toggle::make('features.ai_predictions') + Toggle::make('ai_predictions') ->label('AI predictions'), - Toggle::make('features.price_threshold') + Toggle::make('price_threshold') ->label('Price threshold alerts'), - Toggle::make('features.score_alerts') + Toggle::make('score_alerts') ->label('Score change alerts'), ]), ]); diff --git a/app/Filament/Resources/Plans/Tables/PlansTable.php b/app/Filament/Resources/Plans/Tables/PlansTable.php index a7e751e..06f8e75 100644 --- a/app/Filament/Resources/Plans/Tables/PlansTable.php +++ b/app/Filament/Resources/Plans/Tables/PlansTable.php @@ -17,16 +17,16 @@ class PlansTable ->label('Tier') ->badge() ->sortable(), - TextColumn::make('features.email.frequency') + TextColumn::make('email_frequency') ->label('Email') ->placeholder('—'), - TextColumn::make('features.sms.daily_limit') + TextColumn::make('sms_daily_limit') ->label('SMS/day') ->placeholder('—'), - TextColumn::make('features.whatsapp.daily_limit') + TextColumn::make('whatsapp_daily_limit') ->label('WhatsApp/day') ->placeholder('—'), - TextColumn::make('features.fuel_types.max') + TextColumn::make('max_fuel_types') ->label('Fuel types') ->placeholder('Unlimited'), IconColumn::make('active') diff --git a/app/Jobs/SendScheduledWhatsAppJob.php b/app/Jobs/SendScheduledWhatsAppJob.php index 84fc6eb..294dd5a 100644 --- a/app/Jobs/SendScheduledWhatsAppJob.php +++ b/app/Jobs/SendScheduledWhatsAppJob.php @@ -30,8 +30,7 @@ final class SendScheduledWhatsAppJob implements ShouldQueue // Plans that allow scheduled WhatsApp updates $eligiblePlanNames = Plan::where('active', true) - ->get() - ->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0) + ->where('whatsapp_scheduled_updates', '>', 0) ->pluck('name') ->all(); diff --git a/app/Models/Plan.php b/app/Models/Plan.php index 44a98f9..4fe3fcf 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -17,7 +17,19 @@ class Plan extends Model 'name', 'stripe_price_id_monthly', 'stripe_price_id_annual', - 'features', + 'max_fuel_types', + 'email_enabled', + 'email_frequency', + 'push_enabled', + 'push_frequency', + 'whatsapp_enabled', + 'whatsapp_daily_limit', + 'whatsapp_scheduled_updates', + 'sms_enabled', + 'sms_daily_limit', + 'ai_predictions', + 'price_threshold', + 'score_alerts', 'active', ]; @@ -56,28 +68,7 @@ class Plan extends Model } ); - if ($planId !== null) { - $plan = static::find($planId); - - if ($plan !== null) { - return $plan; - } - } - - // Fallback for tests / partially-seeded environments: return a free-tier stub. - return new self([ - 'name' => PlanTier::Free->value, - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false, 'frequency' => 'none'], - 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => false, - 'score_alerts' => false, - ], - ]); + return static::findOrFail($planId); } /** @@ -127,7 +118,17 @@ class Plan extends Model protected function casts(): array { return [ - 'features' => 'array', + 'max_fuel_types' => 'integer', + 'email_enabled' => 'boolean', + 'push_enabled' => 'boolean', + 'whatsapp_enabled' => 'boolean', + 'whatsapp_daily_limit' => 'integer', + 'whatsapp_scheduled_updates' => 'integer', + 'sms_enabled' => 'boolean', + 'sms_daily_limit' => 'integer', + 'ai_predictions' => 'boolean', + 'price_threshold' => 'boolean', + 'score_alerts' => 'boolean', 'active' => 'boolean', ]; } diff --git a/app/Services/PlanFeatures.php b/app/Services/PlanFeatures.php index 1a21f03..14c363b 100644 --- a/app/Services/PlanFeatures.php +++ b/app/Services/PlanFeatures.php @@ -6,32 +6,17 @@ use App\Models\NotificationLog; use App\Models\Plan; use App\Models\User; use App\Models\UserNotificationPreference; -use Throwable; final class PlanFeatures { + /** @var string[] */ + private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms']; + private Plan $plan; private function __construct(private readonly User $user) { - try { - $this->plan = Plan::resolveForUser($user); - } catch (Throwable) { - // Never throw — fall back to a free-tier stub if resolution fails. - $this->plan = new Plan([ - 'name' => 'free', - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false, 'frequency' => 'none'], - 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => false, - 'score_alerts' => false, - ], - ]); - } + $this->plan = Plan::resolveForUser($user); } public static function for(User $user): self @@ -47,10 +32,9 @@ final class PlanFeatures */ public function channelsFor(string $triggerType): array { - $allChannels = ['email', 'push', 'whatsapp', 'sms']; $allowed = []; - foreach ($allChannels as $channel) { + foreach (self::CHANNELS as $channel) { if (! $this->canUseChannel($channel)) { continue; } @@ -72,24 +56,7 @@ final class PlanFeatures /** Whether the plan allows this channel at all. */ public function canUseChannel(string $channel): bool { - return (bool) ($this->feature($channel, 'enabled') ?? false); - } - - /** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */ - private function feature(string $channel, string $key): mixed - { - $features = $this->plan->features ?? []; - - return $features[$channel][$key] ?? null; - } - - /** Whether the user has opted in to this channel for at least one fuel type. */ - private function userHasEnabledChannel(string $channel): bool - { - return UserNotificationPreference::where('user_id', $this->user->id) - ->where('channel', $channel) - ->where('enabled', true) - ->exists(); + return (bool) $this->plan->{"{$channel}_enabled"}; } /** @@ -102,9 +69,9 @@ final class PlanFeatures return false; } - $dailyLimit = $this->feature($channel, 'daily_limit'); + $dailyLimit = $this->dailyLimit($channel); - // null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited + // null = unlimited; 0 = blocked even though enabled if ($dailyLimit === null) { return true; } @@ -131,9 +98,6 @@ final class PlanFeatures return true; } - $count = $this->trackedFuelTypeCount(); - - // Allow if already tracking this type (not adding a new one) $alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id) ->where('fuel_type', $fuelType) ->exists(); @@ -142,15 +106,13 @@ final class PlanFeatures return true; } - return $count < $limit; + return $this->trackedFuelTypeCount() < $limit; } /** Maximum fuel types allowed, or null for unlimited. */ public function fuelTypeLimit(): ?int { - $features = $this->plan->features ?? []; - - return $features['fuel_types']['max'] ?? 1; + return $this->plan->max_fuel_types; } /** Count of distinct fuel types the user has preferences for. */ @@ -164,9 +126,7 @@ final class PlanFeatures /** Generic boolean feature flag check. */ public function can(string $feature): bool { - $features = $this->plan->features ?? []; - - return (bool) ($features[$feature] ?? false); + return (bool) ($this->plan->{$feature} ?? false); } /** Count of notifications missed today on a channel. */ @@ -193,7 +153,7 @@ final class PlanFeatures /** The resolved plan tier name. */ public function tier(): string { - return $this->plan->name ?? 'free'; + return $this->plan->name; } /** User-facing display label for the resolved tier (e.g. basic → "Daily"). */ @@ -201,4 +161,23 @@ final class PlanFeatures { return $this->plan->displayName(); } + + /** Whether the user has opted in to this channel for at least one fuel type. */ + private function userHasEnabledChannel(string $channel): bool + { + return UserNotificationPreference::where('user_id', $this->user->id) + ->where('channel', $channel) + ->where('enabled', true) + ->exists(); + } + + /** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */ + private function dailyLimit(string $channel): ?int + { + return match ($channel) { + 'whatsapp' => $this->plan->whatsapp_daily_limit, + 'sms' => $this->plan->sms_daily_limit, + default => null, + }; + } } diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php index 71277bd..d771cd5 100644 --- a/database/factories/PlanFactory.php +++ b/database/factories/PlanFactory.php @@ -11,23 +11,25 @@ use Illuminate\Database\Eloquent\Factories\Factory; */ class PlanFactory extends Factory { - private static array $defaultFeatures = [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => false, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false, 'frequency' => 'none'], - 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => false, - 'score_alerts' => false, - ]; - public function definition(): array { return [ 'name' => PlanTier::Free->value, - 'stripe_price_id' => null, - 'features' => self::$defaultFeatures, + 'stripe_price_id_monthly' => null, + 'stripe_price_id_annual' => null, + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'weekly_digest', + 'push_enabled' => false, + 'push_frequency' => 'none', + 'whatsapp_enabled' => false, + 'whatsapp_daily_limit' => 0, + 'whatsapp_scheduled_updates' => 0, + 'sms_enabled' => false, + 'sms_daily_limit' => 0, + 'ai_predictions' => false, + 'price_threshold' => false, + 'score_alerts' => false, 'active' => true, ]; } @@ -36,8 +38,8 @@ class PlanFactory extends Factory { return $this->state(fn () => [ 'name' => PlanTier::Free->value, - 'stripe_price_id' => null, - 'features' => self::$defaultFeatures, + 'stripe_price_id_monthly' => null, + 'stripe_price_id_annual' => null, ]); } @@ -45,17 +47,21 @@ class PlanFactory extends Factory { return $this->state(fn () => [ 'name' => PlanTier::Basic->value, - 'stripe_price_id' => 'price_basic_test', - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'daily'], - 'push' => ['enabled' => true, 'frequency' => 'daily'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'stripe_price_id_monthly' => 'price_basic_monthly_test', + 'stripe_price_id_annual' => 'price_basic_annual_test', + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'daily', + 'push_enabled' => true, + 'push_frequency' => 'daily', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => false, + 'sms_daily_limit' => 0, + 'ai_predictions' => false, + 'price_threshold' => true, + 'score_alerts' => true, ]); } @@ -63,17 +69,21 @@ class PlanFactory extends Factory { return $this->state(fn () => [ 'name' => PlanTier::Plus->value, - 'stripe_price_id' => 'price_plus_test', - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true, 'frequency' => 'triggered'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => true, 'daily_limit' => 1], - 'ai_predictions' => true, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'stripe_price_id_monthly' => 'price_plus_monthly_test', + 'stripe_price_id_annual' => 'price_plus_annual_test', + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'triggered', + 'push_enabled' => true, + 'push_frequency' => 'triggered', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => true, + 'sms_daily_limit' => 1, + 'ai_predictions' => true, + 'price_threshold' => true, + 'score_alerts' => true, ]); } @@ -81,17 +91,21 @@ class PlanFactory extends Factory { return $this->state(fn () => [ 'name' => PlanTier::Pro->value, - 'stripe_price_id' => 'price_pro_test', - 'features' => [ - 'fuel_types' => ['max' => null], - 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true, 'frequency' => 'triggered'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => true, 'daily_limit' => 3], - 'ai_predictions' => true, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'stripe_price_id_monthly' => 'price_pro_monthly_test', + 'stripe_price_id_annual' => 'price_pro_annual_test', + 'max_fuel_types' => null, + 'email_enabled' => true, + 'email_frequency' => 'triggered', + 'push_enabled' => true, + 'push_frequency' => 'triggered', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => true, + 'sms_daily_limit' => 3, + 'ai_predictions' => true, + 'price_threshold' => true, + 'score_alerts' => true, ]); } } diff --git a/database/migrations/2026_04_29_164748_flatten_features_json_on_plans_table.php b/database/migrations/2026_04_29_164748_flatten_features_json_on_plans_table.php new file mode 100644 index 0000000..ce7f7c3 --- /dev/null +++ b/database/migrations/2026_04_29_164748_flatten_features_json_on_plans_table.php @@ -0,0 +1,60 @@ +unsignedTinyInteger('max_fuel_types')->nullable()->after('stripe_price_id_annual') + ->comment('Null = unlimited'); + + $table->boolean('email_enabled')->default(true)->after('max_fuel_types'); + $table->string('email_frequency', 20)->default('weekly_digest')->after('email_enabled') + ->comment('weekly_digest | daily | triggered'); + + $table->boolean('push_enabled')->default(false)->after('email_frequency'); + $table->string('push_frequency', 20)->default('none')->after('push_enabled') + ->comment('none | daily | triggered'); + + $table->boolean('whatsapp_enabled')->default(false)->after('push_frequency'); + $table->unsignedSmallInteger('whatsapp_daily_limit')->default(0)->after('whatsapp_enabled'); + $table->unsignedTinyInteger('whatsapp_scheduled_updates')->default(0)->after('whatsapp_daily_limit'); + + $table->boolean('sms_enabled')->default(false)->after('whatsapp_scheduled_updates'); + $table->unsignedSmallInteger('sms_daily_limit')->default(0)->after('sms_enabled'); + + $table->boolean('ai_predictions')->default(false)->after('sms_daily_limit'); + $table->boolean('price_threshold')->default(false)->after('ai_predictions'); + $table->boolean('score_alerts')->default(false)->after('price_threshold'); + + $table->dropColumn('features'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table): void { + $table->json('features')->after('stripe_price_id_annual'); + + $table->dropColumn([ + 'max_fuel_types', + 'email_enabled', + 'email_frequency', + 'push_enabled', + 'push_frequency', + 'whatsapp_enabled', + 'whatsapp_daily_limit', + 'whatsapp_scheduled_updates', + 'sms_enabled', + 'sms_daily_limit', + 'ai_predictions', + 'price_threshold', + 'score_alerts', + ]); + }); + } +}; diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 86a66ca..7e50ae4 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -14,71 +14,75 @@ class PlanSeeder extends Seeder PlanTier::Free->value => [ 'stripe_price_id_monthly' => null, 'stripe_price_id_annual' => null, - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], - 'push' => ['enabled' => false, 'frequency' => 'none'], - 'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => false, - 'score_alerts' => false, - ], + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'weekly_digest', + 'push_enabled' => false, + 'push_frequency' => 'none', + 'whatsapp_enabled' => false, + 'whatsapp_daily_limit' => 0, + 'whatsapp_scheduled_updates' => 0, + 'sms_enabled' => false, + 'sms_daily_limit' => 0, + 'ai_predictions' => false, + 'price_threshold' => false, + 'score_alerts' => false, ], PlanTier::Basic->value => [ 'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'), 'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'), - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'daily'], - 'push' => ['enabled' => true, 'frequency' => 'daily'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => false, 'daily_limit' => 0], - 'ai_predictions' => false, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'daily', + 'push_enabled' => true, + 'push_frequency' => 'daily', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => false, + 'sms_daily_limit' => 0, + 'ai_predictions' => false, + 'price_threshold' => true, + 'score_alerts' => true, ], PlanTier::Plus->value => [ 'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'), 'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'), - 'features' => [ - 'fuel_types' => ['max' => 1], - 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true, 'frequency' => 'triggered'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => true, 'daily_limit' => 1], - 'ai_predictions' => true, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'max_fuel_types' => 1, + 'email_enabled' => true, + 'email_frequency' => 'triggered', + 'push_enabled' => true, + 'push_frequency' => 'triggered', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => true, + 'sms_daily_limit' => 1, + 'ai_predictions' => true, + 'price_threshold' => true, + 'score_alerts' => true, ], PlanTier::Pro->value => [ 'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'), 'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'), - 'features' => [ - 'fuel_types' => ['max' => null], - 'email' => ['enabled' => true, 'frequency' => 'triggered'], - 'push' => ['enabled' => true, 'frequency' => 'triggered'], - 'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], - 'sms' => ['enabled' => true, 'daily_limit' => 3], - 'ai_predictions' => true, - 'price_threshold' => true, - 'score_alerts' => true, - ], + 'max_fuel_types' => null, + 'email_enabled' => true, + 'email_frequency' => 'triggered', + 'push_enabled' => true, + 'push_frequency' => 'triggered', + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + 'sms_enabled' => true, + 'sms_daily_limit' => 3, + 'ai_predictions' => true, + 'price_threshold' => true, + 'score_alerts' => true, ], ]; - foreach ($plans as $name => $data) { - Plan::updateOrCreate( - ['name' => $name], - [ - 'stripe_price_id_monthly' => $data['stripe_price_id_monthly'], - 'stripe_price_id_annual' => $data['stripe_price_id_annual'], - 'features' => $data['features'], - 'active' => true, - ] - ); + foreach ($plans as $name => $attributes) { + Plan::updateOrCreate(['name' => $name], [...$attributes, 'active' => true]); } } } diff --git a/tests/Feature/Api/AuthControllerTest.php b/tests/Feature/Api/AuthControllerTest.php index 4ff5d85..86a49d6 100644 --- a/tests/Feature/Api/AuthControllerTest.php +++ b/tests/Feature/Api/AuthControllerTest.php @@ -114,12 +114,9 @@ it('reports subscription_cancelled=true once the subscription is set to end at p }); it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () { - Plan::create([ - 'name' => 'plus', + Plan::where('name', 'plus')->update([ 'stripe_price_id_monthly' => 'price_plus_monthly_test', 'stripe_price_id_annual' => 'price_plus_annual_test', - 'features' => ['fuel_types' => ['max' => 1]], - 'active' => true, ]); $user = User::factory()->create(); @@ -149,12 +146,9 @@ it('exposes subscribed_at, cadence and renewal date for an active monthly subscr }); it('reports cadence as annual when the active price is the annual one', function () { - Plan::create([ - 'name' => 'pro', + Plan::where('name', 'pro')->update([ 'stripe_price_id_monthly' => 'price_pro_monthly_test', 'stripe_price_id_annual' => 'price_pro_annual_test', - 'features' => ['fuel_types' => ['max' => null]], - 'active' => true, ]); $user = User::factory()->create(); diff --git a/tests/Feature/Tiers/DispatchUserNotificationJobTest.php b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php index ddc3b45..acdf113 100644 --- a/tests/Feature/Tiers/DispatchUserNotificationJobTest.php +++ b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php @@ -69,11 +69,10 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu $user = User::factory()->create(); // Patch the free plan to allow sms with limit 1 - $freePlan = Plan::where('name', 'free')->first(); - $features = $freePlan->features; - $features['sms'] = ['enabled' => true, 'daily_limit' => 1]; - $freePlan->features = $features; - $freePlan->save(); + Plan::where('name', 'free')->first()->update([ + 'sms_enabled' => true, + 'sms_daily_limit' => 1, + ]); UserNotificationPreference::factory()->create([ 'user_id' => $user->id, @@ -106,14 +105,11 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu it('does not log channels the user has explicitly disabled', function (): void { $user = User::factory()->create(); - // Patch free plan to allow sms - $freePlan = Plan::where('name', 'free')->first(); - $features = $freePlan->features; - $features['sms'] = ['enabled' => true, 'daily_limit' => 3]; - $freePlan->features = $features; - $freePlan->save(); + Plan::where('name', 'free')->first()->update([ + 'sms_enabled' => true, + 'sms_daily_limit' => 3, + ]); - // User has sms pref but it is disabled UserNotificationPreference::factory()->create([ 'user_id' => $user->id, 'channel' => 'sms', @@ -145,12 +141,11 @@ it('dispatches DispatchUserNotificationJob for eligible whatsapp users', functio $user = User::factory()->create(); - // Patch free plan to allow whatsapp with scheduled updates - $freePlan = Plan::where('name', 'free')->first(); - $features = $freePlan->features; - $features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2]; - $freePlan->features = $features; - $freePlan->save(); + Plan::where('name', 'free')->first()->update([ + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + ]); UserNotificationPreference::factory()->create([ 'user_id' => $user->id, @@ -171,11 +166,11 @@ it('skips users who have hit their whatsapp daily limit', function (): void { $user = User::factory()->create(); - $freePlan = Plan::where('name', 'free')->first(); - $features = $freePlan->features; - $features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2]; - $freePlan->features = $features; - $freePlan->save(); + Plan::where('name', 'free')->first()->update([ + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 1, + 'whatsapp_scheduled_updates' => 2, + ]); UserNotificationPreference::factory()->create([ 'user_id' => $user->id, @@ -184,7 +179,6 @@ it('skips users who have hit their whatsapp daily limit', function (): void { 'enabled' => true, ]); - // Exhaust the daily limit NotificationLog::factory()->create([ 'user_id' => $user->id, 'channel' => 'whatsapp', @@ -204,11 +198,11 @@ it('passes scheduled_morning trigger for morning period', function (): void { $user = User::factory()->create(); - $freePlan = Plan::where('name', 'free')->first(); - $features = $freePlan->features; - $features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2]; - $freePlan->features = $features; - $freePlan->save(); + Plan::where('name', 'free')->first()->update([ + 'whatsapp_enabled' => true, + 'whatsapp_daily_limit' => 5, + 'whatsapp_scheduled_updates' => 2, + ]); UserNotificationPreference::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Feature/Tiers/PlanFeaturesTest.php b/tests/Feature/Tiers/PlanFeaturesTest.php index c008a0e..1c40e20 100644 --- a/tests/Feature/Tiers/PlanFeaturesTest.php +++ b/tests/Feature/Tiers/PlanFeaturesTest.php @@ -28,20 +28,19 @@ it('canUseChannel returns false for sms on free tier', function (): void { it('canUseChannel returns false for sms on basic tier', function (): void { $plan = Plan::where('name', 'basic')->first(); - // basic has sms.enabled = false in features - expect($plan->features['sms']['enabled'])->toBeFalse(); + expect($plan->sms_enabled)->toBeFalse(); }); it('canUseChannel returns true for sms on plus tier', function (): void { $plan = Plan::where('name', 'plus')->first(); - expect($plan->features['sms']['enabled'])->toBeTrue(); + expect($plan->sms_enabled)->toBeTrue(); }); it('canUseChannel returns true for sms on pro tier', function (): void { $plan = Plan::where('name', 'pro')->first(); - expect($plan->features['sms']['enabled'])->toBeTrue(); + expect($plan->sms_enabled)->toBeTrue(); }); // ─── canSendNow ─────────────────────────────────────────────────────────────── @@ -54,10 +53,9 @@ it('canSendNow returns false when tier does not allow the channel', function (): }); it('canSendNow returns false when daily limit is reached', function (): void { - $plan = Plan::where('name', 'plus')->first(); // sms daily_limit = 1 + $plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 1 $user = User::factory()->create(); - // Give user a preference so channelsFor works, and log one sent SMS today UserNotificationPreference::factory()->create([ 'user_id' => $user->id, 'channel' => 'sms', @@ -72,10 +70,8 @@ it('canSendNow returns false when daily limit is reached', function (): void { 'created_at' => now(), ]); - // Manually bypass resolveForUser by using the plus plan features directly - expect($plan->features['sms']['daily_limit'])->toBe(1); + expect($plan->sms_daily_limit)->toBe(1); - // Confirm log count matches limit $sentCount = NotificationLog::where('user_id', $user->id) ->where('channel', 'sms') ->where('sent', true) @@ -88,7 +84,7 @@ it('canSendNow returns false when daily limit is reached', function (): void { // ─── canTrackFuelType ───────────────────────────────────────────────────────── it('canTrackFuelType respects max limit for non-pro tiers', function (): void { - $plan = Plan::where('name', 'basic')->first(); // max = 1 + $plan = Plan::where('name', 'basic')->first(); // max_fuel_types = 1 $user = User::factory()->create(); UserNotificationPreference::factory()->create([ @@ -98,7 +94,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void { 'enabled' => true, ]); - expect($plan->features['fuel_types']['max'])->toBe(1); + expect($plan->max_fuel_types)->toBe(1); $count = UserNotificationPreference::where('user_id', $user->id) ->distinct('fuel_type') @@ -110,7 +106,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void { it('pro tier has null fuel type limit meaning unlimited', function (): void { $plan = Plan::where('name', 'pro')->first(); - expect($plan->features['fuel_types']['max'])->toBeNull(); + expect($plan->max_fuel_types)->toBeNull(); }); // ─── can() feature flags ────────────────────────────────────────────────────── @@ -118,19 +114,18 @@ it('pro tier has null fuel type limit meaning unlimited', function (): void { it('can returns false for ai_predictions on free tier', function (): void { $plan = Plan::where('name', 'free')->first(); - expect($plan->features['ai_predictions'])->toBeFalse(); + expect($plan->ai_predictions)->toBeFalse(); }); it('can returns true for ai_predictions on plus tier', function (): void { $plan = Plan::where('name', 'plus')->first(); - expect($plan->features['ai_predictions'])->toBeTrue(); + expect($plan->ai_predictions)->toBeTrue(); }); // ─── PlanSeeder idempotency ─────────────────────────────────────────────────── it('PlanSeeder is idempotent', function (): void { - // Run seeder a second time $this->artisan('db:seed', ['--class' => 'PlanSeeder']); expect(Plan::count())->toBe(4); @@ -211,15 +206,15 @@ it('scopeForFuelType filters by fuel type', function (): void { // ─── push frequency ─────────────────────────────────────────────────────────── -it('seeds push.frequency for every tier', function (): void { - expect(Plan::where('name', 'free')->first()->features['push']) - ->toBe(['enabled' => false, 'frequency' => 'none']) - ->and(Plan::where('name', 'basic')->first()->features['push']) - ->toBe(['enabled' => true, 'frequency' => 'daily']) - ->and(Plan::where('name', 'plus')->first()->features['push']) - ->toBe(['enabled' => true, 'frequency' => 'triggered']) - ->and(Plan::where('name', 'pro')->first()->features['push']) - ->toBe(['enabled' => true, 'frequency' => 'triggered']); +it('seeds push frequency for every tier', function (): void { + expect(Plan::where('name', 'free')->first()) + ->push_enabled->toBeFalse()->push_frequency->toBe('none') + ->and(Plan::where('name', 'basic')->first()) + ->push_enabled->toBeTrue()->push_frequency->toBe('daily') + ->and(Plan::where('name', 'plus')->first()) + ->push_enabled->toBeTrue()->push_frequency->toBe('triggered') + ->and(Plan::where('name', 'pro')->first()) + ->push_enabled->toBeTrue()->push_frequency->toBe('triggered'); }); // ─── display name ───────────────────────────────────────────────────────────── diff --git a/tests/Feature/Tiers/PlanResourceTest.php b/tests/Feature/Tiers/PlanResourceTest.php index 8694fbc..43aa639 100644 --- a/tests/Feature/Tiers/PlanResourceTest.php +++ b/tests/Feature/Tiers/PlanResourceTest.php @@ -43,12 +43,12 @@ it('saves email frequency on edit', function (): void { Livewire::test(EditPlan::class, ['record' => $plan->id]) ->fillForm([ - 'features.email.frequency' => 'daily', + 'email_frequency' => 'daily', ]) ->call('save') ->assertHasNoFormErrors(); - expect($plan->fresh()->features['email']['frequency'])->toBe('daily'); + expect($plan->fresh()->email_frequency)->toBe('daily'); }); it('saves sms daily limit on edit', function (): void { @@ -56,12 +56,12 @@ it('saves sms daily limit on edit', function (): void { Livewire::test(EditPlan::class, ['record' => $plan->id]) ->fillForm([ - 'features.sms.daily_limit' => 3, + 'sms_daily_limit' => 3, ]) ->call('save') ->assertHasNoFormErrors(); - expect($plan->fresh()->features['sms']['daily_limit'])->toBe(3); + expect($plan->fresh()->sms_daily_limit)->toBe(3); }); it('saves null fuel type max for pro (unlimited)', function (): void { @@ -69,10 +69,10 @@ it('saves null fuel type max for pro (unlimited)', function (): void { Livewire::test(EditPlan::class, ['record' => $plan->id]) ->fillForm([ - 'features.fuel_types.max' => null, + 'max_fuel_types' => null, ]) ->call('save') ->assertHasNoFormErrors(); - expect($plan->fresh()->features['fuel_types']['max'])->toBeNull(); + expect($plan->fresh()->max_fuel_types)->toBeNull(); }); diff --git a/tests/Pest.php b/tests/Pest.php index f2e3997..624f5e2 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,5 +1,6 @@ extend(TestCase::class) ->use(RefreshDatabase::class) + ->beforeEach(function (): void { + $this->seed(PlanSeeder::class); + }) ->in('Feature', 'Unit'); /*