From 8695d5ec95bb58399c9171217d479ae8fb57f0ba Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Wed, 29 Apr 2026 18:13:26 +0100 Subject: [PATCH] refactor: flatten plans.features JSON to typed columns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The features JSON column required defensive fallback stubs in three places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder) and silently swallowed misspelled keys. Typed columns give Eloquent type-safe reads, simplify the Filament form (no more dotted JSON paths), and let resolveForUser fail loud when the free row is missing. PlanFeatures public API is unchanged so consumers (jobs, middleware) need no rewrites — one missed JSON read in SendScheduledWhatsAppJob was caught and converted to a typed where() query. tests/Pest.php seeds PlanSeeder in beforeEach so any feature test that resolves a plan finds the free row, mirroring production where plans always exist. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Resources/Plans/Schemas/PlanForm.php | 26 ++--- .../Resources/Plans/Tables/PlansTable.php | 8 +- app/Jobs/SendScheduledWhatsAppJob.php | 3 +- app/Models/Plan.php | 49 ++++---- app/Services/PlanFeatures.php | 83 +++++-------- database/factories/PlanFactory.php | 110 ++++++++++-------- ...8_flatten_features_json_on_plans_table.php | 60 ++++++++++ database/seeders/PlanSeeder.php | 104 +++++++++-------- tests/Feature/Api/AuthControllerTest.php | 10 +- .../Tiers/DispatchUserNotificationJobTest.php | 52 ++++----- tests/Feature/Tiers/PlanFeaturesTest.php | 43 +++---- tests/Feature/Tiers/PlanResourceTest.php | 12 +- tests/Pest.php | 4 + 13 files changed, 304 insertions(+), 260 deletions(-) create mode 100644 database/migrations/2026_04_29_164748_flatten_features_json_on_plans_table.php 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'); /*