refactor: flatten plans.features JSON to typed columns

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) <noreply@anthropic.com>
This commit is contained in:
Ovidiu U
2026-04-29 18:13:26 +01:00
parent 088fd11058
commit 8695d5ec95
13 changed files with 304 additions and 260 deletions

View File

@@ -16,7 +16,7 @@ class PlanForm
->components([ ->components([
Section::make('Fuel Types') Section::make('Fuel Types')
->schema([ ->schema([
TextInput::make('features.fuel_types.max') TextInput::make('max_fuel_types')
->label('Max fuel types') ->label('Max fuel types')
->helperText('Leave blank for unlimited.') ->helperText('Leave blank for unlimited.')
->numeric() ->numeric()
@@ -28,9 +28,9 @@ class PlanForm
Section::make('Email') Section::make('Email')
->columns(2) ->columns(2)
->schema([ ->schema([
Toggle::make('features.email.enabled') Toggle::make('email_enabled')
->label('Enabled'), ->label('Enabled'),
Select::make('features.email.frequency') Select::make('email_frequency')
->label('Frequency') ->label('Frequency')
->options([ ->options([
'weekly_digest' => 'Weekly digest', 'weekly_digest' => 'Weekly digest',
@@ -42,9 +42,9 @@ class PlanForm
Section::make('Push') Section::make('Push')
->columns(2) ->columns(2)
->schema([ ->schema([
Toggle::make('features.push.enabled') Toggle::make('push_enabled')
->label('Enabled'), ->label('Enabled'),
Select::make('features.push.frequency') Select::make('push_frequency')
->label('Frequency') ->label('Frequency')
->options([ ->options([
'none' => 'None (disabled)', 'none' => 'None (disabled)',
@@ -56,15 +56,15 @@ class PlanForm
Section::make('WhatsApp') Section::make('WhatsApp')
->columns(3) ->columns(3)
->schema([ ->schema([
Toggle::make('features.whatsapp.enabled') Toggle::make('whatsapp_enabled')
->label('Enabled'), ->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit') TextInput::make('whatsapp_daily_limit')
->label('Daily limit') ->label('Daily limit')
->numeric() ->numeric()
->integer() ->integer()
->minValue(0) ->minValue(0)
->required(), ->required(),
TextInput::make('features.whatsapp.scheduled_updates') TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day') ->label('Scheduled updates per day')
->numeric() ->numeric()
->integer() ->integer()
@@ -75,9 +75,9 @@ class PlanForm
Section::make('SMS') Section::make('SMS')
->columns(2) ->columns(2)
->schema([ ->schema([
Toggle::make('features.sms.enabled') Toggle::make('sms_enabled')
->label('Enabled'), ->label('Enabled'),
TextInput::make('features.sms.daily_limit') TextInput::make('sms_daily_limit')
->label('Daily limit') ->label('Daily limit')
->numeric() ->numeric()
->integer() ->integer()
@@ -87,11 +87,11 @@ class PlanForm
Section::make('Features') Section::make('Features')
->schema([ ->schema([
Toggle::make('features.ai_predictions') Toggle::make('ai_predictions')
->label('AI predictions'), ->label('AI predictions'),
Toggle::make('features.price_threshold') Toggle::make('price_threshold')
->label('Price threshold alerts'), ->label('Price threshold alerts'),
Toggle::make('features.score_alerts') Toggle::make('score_alerts')
->label('Score change alerts'), ->label('Score change alerts'),
]), ]),
]); ]);

View File

@@ -17,16 +17,16 @@ class PlansTable
->label('Tier') ->label('Tier')
->badge() ->badge()
->sortable(), ->sortable(),
TextColumn::make('features.email.frequency') TextColumn::make('email_frequency')
->label('Email') ->label('Email')
->placeholder('—'), ->placeholder('—'),
TextColumn::make('features.sms.daily_limit') TextColumn::make('sms_daily_limit')
->label('SMS/day') ->label('SMS/day')
->placeholder('—'), ->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit') TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day') ->label('WhatsApp/day')
->placeholder('—'), ->placeholder('—'),
TextColumn::make('features.fuel_types.max') TextColumn::make('max_fuel_types')
->label('Fuel types') ->label('Fuel types')
->placeholder('Unlimited'), ->placeholder('Unlimited'),
IconColumn::make('active') IconColumn::make('active')

View File

@@ -30,8 +30,7 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
// Plans that allow scheduled WhatsApp updates // Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true) $eligiblePlanNames = Plan::where('active', true)
->get() ->where('whatsapp_scheduled_updates', '>', 0)
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->pluck('name') ->pluck('name')
->all(); ->all();

View File

@@ -17,7 +17,19 @@ class Plan extends Model
'name', 'name',
'stripe_price_id_monthly', 'stripe_price_id_monthly',
'stripe_price_id_annual', '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', 'active',
]; ];
@@ -56,28 +68,7 @@ class Plan extends Model
} }
); );
if ($planId !== null) { return static::findOrFail($planId);
$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,
],
]);
} }
/** /**
@@ -127,7 +118,17 @@ class Plan extends Model
protected function casts(): array protected function casts(): array
{ {
return [ 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', 'active' => 'boolean',
]; ];
} }

View File

@@ -6,32 +6,17 @@ use App\Models\NotificationLog;
use App\Models\Plan; use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures final class PlanFeatures
{ {
/** @var string[] */
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
private Plan $plan; private Plan $plan;
private function __construct(private readonly User $user) private function __construct(private readonly User $user)
{ {
try {
$this->plan = Plan::resolveForUser($user); $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,
],
]);
}
} }
public static function for(User $user): self public static function for(User $user): self
@@ -47,10 +32,9 @@ final class PlanFeatures
*/ */
public function channelsFor(string $triggerType): array public function channelsFor(string $triggerType): array
{ {
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = []; $allowed = [];
foreach ($allChannels as $channel) { foreach (self::CHANNELS as $channel) {
if (! $this->canUseChannel($channel)) { if (! $this->canUseChannel($channel)) {
continue; continue;
} }
@@ -72,24 +56,7 @@ final class PlanFeatures
/** Whether the plan allows this channel at all. */ /** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool public function canUseChannel(string $channel): bool
{ {
return (bool) ($this->feature($channel, 'enabled') ?? false); return (bool) $this->plan->{"{$channel}_enabled"};
}
/** 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();
} }
/** /**
@@ -102,9 +69,9 @@ final class PlanFeatures
return false; 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) { if ($dailyLimit === null) {
return true; return true;
} }
@@ -131,9 +98,6 @@ final class PlanFeatures
return true; return true;
} }
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id) $alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType) ->where('fuel_type', $fuelType)
->exists(); ->exists();
@@ -142,15 +106,13 @@ final class PlanFeatures
return true; return true;
} }
return $count < $limit; return $this->trackedFuelTypeCount() < $limit;
} }
/** Maximum fuel types allowed, or null for unlimited. */ /** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int public function fuelTypeLimit(): ?int
{ {
$features = $this->plan->features ?? []; return $this->plan->max_fuel_types;
return $features['fuel_types']['max'] ?? 1;
} }
/** Count of distinct fuel types the user has preferences for. */ /** Count of distinct fuel types the user has preferences for. */
@@ -164,9 +126,7 @@ final class PlanFeatures
/** Generic boolean feature flag check. */ /** Generic boolean feature flag check. */
public function can(string $feature): bool public function can(string $feature): bool
{ {
$features = $this->plan->features ?? []; return (bool) ($this->plan->{$feature} ?? false);
return (bool) ($features[$feature] ?? false);
} }
/** Count of notifications missed today on a channel. */ /** Count of notifications missed today on a channel. */
@@ -193,7 +153,7 @@ final class PlanFeatures
/** The resolved plan tier name. */ /** The resolved plan tier name. */
public function tier(): string 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"). */ /** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
@@ -201,4 +161,23 @@ final class PlanFeatures
{ {
return $this->plan->displayName(); 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,
};
}
} }

View File

@@ -11,23 +11,25 @@ use Illuminate\Database\Eloquent\Factories\Factory;
*/ */
class PlanFactory extends 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 public function definition(): array
{ {
return [ return [
'name' => PlanTier::Free->value, 'name' => PlanTier::Free->value,
'stripe_price_id' => null, 'stripe_price_id_monthly' => null,
'features' => self::$defaultFeatures, '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, 'active' => true,
]; ];
} }
@@ -36,8 +38,8 @@ class PlanFactory extends Factory
{ {
return $this->state(fn () => [ return $this->state(fn () => [
'name' => PlanTier::Free->value, 'name' => PlanTier::Free->value,
'stripe_price_id' => null, 'stripe_price_id_monthly' => null,
'features' => self::$defaultFeatures, 'stripe_price_id_annual' => null,
]); ]);
} }
@@ -45,17 +47,21 @@ class PlanFactory extends Factory
{ {
return $this->state(fn () => [ return $this->state(fn () => [
'name' => PlanTier::Basic->value, 'name' => PlanTier::Basic->value,
'stripe_price_id' => 'price_basic_test', 'stripe_price_id_monthly' => 'price_basic_monthly_test',
'features' => [ 'stripe_price_id_annual' => 'price_basic_annual_test',
'fuel_types' => ['max' => 1], 'max_fuel_types' => 1,
'email' => ['enabled' => true, 'frequency' => 'daily'], 'email_enabled' => true,
'push' => ['enabled' => true, 'frequency' => 'daily'], 'email_frequency' => 'daily',
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_enabled' => true,
'sms' => ['enabled' => false, 'daily_limit' => 0], 'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false, 'ai_predictions' => false,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
],
]); ]);
} }
@@ -63,17 +69,21 @@ class PlanFactory extends Factory
{ {
return $this->state(fn () => [ return $this->state(fn () => [
'name' => PlanTier::Plus->value, 'name' => PlanTier::Plus->value,
'stripe_price_id' => 'price_plus_test', 'stripe_price_id_monthly' => 'price_plus_monthly_test',
'features' => [ 'stripe_price_id_annual' => 'price_plus_annual_test',
'fuel_types' => ['max' => 1], 'max_fuel_types' => 1,
'email' => ['enabled' => true, 'frequency' => 'triggered'], 'email_enabled' => true,
'push' => ['enabled' => true, 'frequency' => 'triggered'], 'email_frequency' => 'triggered',
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_enabled' => true,
'sms' => ['enabled' => true, 'daily_limit' => 1], 'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
],
]); ]);
} }
@@ -81,17 +91,21 @@ class PlanFactory extends Factory
{ {
return $this->state(fn () => [ return $this->state(fn () => [
'name' => PlanTier::Pro->value, 'name' => PlanTier::Pro->value,
'stripe_price_id' => 'price_pro_test', 'stripe_price_id_monthly' => 'price_pro_monthly_test',
'features' => [ 'stripe_price_id_annual' => 'price_pro_annual_test',
'fuel_types' => ['max' => null], 'max_fuel_types' => null,
'email' => ['enabled' => true, 'frequency' => 'triggered'], 'email_enabled' => true,
'push' => ['enabled' => true, 'frequency' => 'triggered'], 'email_frequency' => 'triggered',
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_enabled' => true,
'sms' => ['enabled' => true, 'daily_limit' => 3], 'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
],
]); ]);
} }
} }

View File

@@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->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',
]);
});
}
};

View File

@@ -14,71 +14,75 @@ class PlanSeeder extends Seeder
PlanTier::Free->value => [ PlanTier::Free->value => [
'stripe_price_id_monthly' => null, 'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null, 'stripe_price_id_annual' => null,
'features' => [ 'max_fuel_types' => 1,
'fuel_types' => ['max' => 1], 'email_enabled' => true,
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], 'email_frequency' => 'weekly_digest',
'push' => ['enabled' => false, 'frequency' => 'none'], 'push_enabled' => false,
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0], 'push_frequency' => 'none',
'sms' => ['enabled' => false, 'daily_limit' => 0], 'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false, 'ai_predictions' => false,
'price_threshold' => false, 'price_threshold' => false,
'score_alerts' => false, 'score_alerts' => false,
], ],
],
PlanTier::Basic->value => [ PlanTier::Basic->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'), 'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'), 'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'),
'features' => [ 'max_fuel_types' => 1,
'fuel_types' => ['max' => 1], 'email_enabled' => true,
'email' => ['enabled' => true, 'frequency' => 'daily'], 'email_frequency' => 'daily',
'push' => ['enabled' => true, 'frequency' => 'daily'], 'push_enabled' => true,
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_frequency' => 'daily',
'sms' => ['enabled' => false, 'daily_limit' => 0], 'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false, 'ai_predictions' => false,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
], ],
],
PlanTier::Plus->value => [ PlanTier::Plus->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'), 'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'), 'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'),
'features' => [ 'max_fuel_types' => 1,
'fuel_types' => ['max' => 1], 'email_enabled' => true,
'email' => ['enabled' => true, 'frequency' => 'triggered'], 'email_frequency' => 'triggered',
'push' => ['enabled' => true, 'frequency' => 'triggered'], 'push_enabled' => true,
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_frequency' => 'triggered',
'sms' => ['enabled' => true, 'daily_limit' => 1], 'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
], ],
],
PlanTier::Pro->value => [ PlanTier::Pro->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'), 'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'), 'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'),
'features' => [ 'max_fuel_types' => null,
'fuel_types' => ['max' => null], 'email_enabled' => true,
'email' => ['enabled' => true, 'frequency' => 'triggered'], 'email_frequency' => 'triggered',
'push' => ['enabled' => true, 'frequency' => 'triggered'], 'push_enabled' => true,
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2], 'push_frequency' => 'triggered',
'sms' => ['enabled' => true, 'daily_limit' => 3], 'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true, 'ai_predictions' => true,
'price_threshold' => true, 'price_threshold' => true,
'score_alerts' => true, 'score_alerts' => true,
], ],
],
]; ];
foreach ($plans as $name => $data) { foreach ($plans as $name => $attributes) {
Plan::updateOrCreate( Plan::updateOrCreate(['name' => $name], [...$attributes, 'active' => true]);
['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,
]
);
} }
} }
} }

View File

@@ -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 () { it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
Plan::create([ Plan::where('name', 'plus')->update([
'name' => 'plus',
'stripe_price_id_monthly' => 'price_plus_monthly_test', 'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test', 'stripe_price_id_annual' => 'price_plus_annual_test',
'features' => ['fuel_types' => ['max' => 1]],
'active' => true,
]); ]);
$user = User::factory()->create(); $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 () { it('reports cadence as annual when the active price is the annual one', function () {
Plan::create([ Plan::where('name', 'pro')->update([
'name' => 'pro',
'stripe_price_id_monthly' => 'price_pro_monthly_test', 'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test', 'stripe_price_id_annual' => 'price_pro_annual_test',
'features' => ['fuel_types' => ['max' => null]],
'active' => true,
]); ]);
$user = User::factory()->create(); $user = User::factory()->create();

View File

@@ -69,11 +69,10 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu
$user = User::factory()->create(); $user = User::factory()->create();
// Patch the free plan to allow sms with limit 1 // Patch the free plan to allow sms with limit 1
$freePlan = Plan::where('name', 'free')->first(); Plan::where('name', 'free')->first()->update([
$features = $freePlan->features; 'sms_enabled' => true,
$features['sms'] = ['enabled' => true, 'daily_limit' => 1]; 'sms_daily_limit' => 1,
$freePlan->features = $features; ]);
$freePlan->save();
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, '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 { it('does not log channels the user has explicitly disabled', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
// Patch free plan to allow sms Plan::where('name', 'free')->first()->update([
$freePlan = Plan::where('name', 'free')->first(); 'sms_enabled' => true,
$features = $freePlan->features; 'sms_daily_limit' => 3,
$features['sms'] = ['enabled' => true, 'daily_limit' => 3]; ]);
$freePlan->features = $features;
$freePlan->save();
// User has sms pref but it is disabled
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'channel' => 'sms', 'channel' => 'sms',
@@ -145,12 +141,11 @@ it('dispatches DispatchUserNotificationJob for eligible whatsapp users', functio
$user = User::factory()->create(); $user = User::factory()->create();
// Patch free plan to allow whatsapp with scheduled updates Plan::where('name', 'free')->first()->update([
$freePlan = Plan::where('name', 'free')->first(); 'whatsapp_enabled' => true,
$features = $freePlan->features; 'whatsapp_daily_limit' => 5,
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2]; 'whatsapp_scheduled_updates' => 2,
$freePlan->features = $features; ]);
$freePlan->save();
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@@ -171,11 +166,11 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first(); Plan::where('name', 'free')->first()->update([
$features = $freePlan->features; 'whatsapp_enabled' => true,
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2]; 'whatsapp_daily_limit' => 1,
$freePlan->features = $features; 'whatsapp_scheduled_updates' => 2,
$freePlan->save(); ]);
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
@@ -184,7 +179,6 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
'enabled' => true, 'enabled' => true,
]); ]);
// Exhaust the daily limit
NotificationLog::factory()->create([ NotificationLog::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'channel' => 'whatsapp', 'channel' => 'whatsapp',
@@ -204,11 +198,11 @@ it('passes scheduled_morning trigger for morning period', function (): void {
$user = User::factory()->create(); $user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first(); Plan::where('name', 'free')->first()->update([
$features = $freePlan->features; 'whatsapp_enabled' => true,
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2]; 'whatsapp_daily_limit' => 5,
$freePlan->features = $features; 'whatsapp_scheduled_updates' => 2,
$freePlan->save(); ]);
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,

View File

@@ -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 { it('canUseChannel returns false for sms on basic tier', function (): void {
$plan = Plan::where('name', 'basic')->first(); $plan = Plan::where('name', 'basic')->first();
// basic has sms.enabled = false in features expect($plan->sms_enabled)->toBeFalse();
expect($plan->features['sms']['enabled'])->toBeFalse();
}); });
it('canUseChannel returns true for sms on plus tier', function (): void { it('canUseChannel returns true for sms on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first(); $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 { it('canUseChannel returns true for sms on pro tier', function (): void {
$plan = Plan::where('name', 'pro')->first(); $plan = Plan::where('name', 'pro')->first();
expect($plan->features['sms']['enabled'])->toBeTrue(); expect($plan->sms_enabled)->toBeTrue();
}); });
// ─── canSendNow ─────────────────────────────────────────────────────────────── // ─── 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 { 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(); $user = User::factory()->create();
// Give user a preference so channelsFor works, and log one sent SMS today
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'channel' => 'sms', 'channel' => 'sms',
@@ -72,10 +70,8 @@ it('canSendNow returns false when daily limit is reached', function (): void {
'created_at' => now(), 'created_at' => now(),
]); ]);
// Manually bypass resolveForUser by using the plus plan features directly expect($plan->sms_daily_limit)->toBe(1);
expect($plan->features['sms']['daily_limit'])->toBe(1);
// Confirm log count matches limit
$sentCount = NotificationLog::where('user_id', $user->id) $sentCount = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms') ->where('channel', 'sms')
->where('sent', true) ->where('sent', true)
@@ -88,7 +84,7 @@ it('canSendNow returns false when daily limit is reached', function (): void {
// ─── canTrackFuelType ───────────────────────────────────────────────────────── // ─── canTrackFuelType ─────────────────────────────────────────────────────────
it('canTrackFuelType respects max limit for non-pro tiers', function (): void { 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(); $user = User::factory()->create();
UserNotificationPreference::factory()->create([ UserNotificationPreference::factory()->create([
@@ -98,7 +94,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
'enabled' => true, 'enabled' => true,
]); ]);
expect($plan->features['fuel_types']['max'])->toBe(1); expect($plan->max_fuel_types)->toBe(1);
$count = UserNotificationPreference::where('user_id', $user->id) $count = UserNotificationPreference::where('user_id', $user->id)
->distinct('fuel_type') ->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 { it('pro tier has null fuel type limit meaning unlimited', function (): void {
$plan = Plan::where('name', 'pro')->first(); $plan = Plan::where('name', 'pro')->first();
expect($plan->features['fuel_types']['max'])->toBeNull(); expect($plan->max_fuel_types)->toBeNull();
}); });
// ─── can() feature flags ────────────────────────────────────────────────────── // ─── 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 { it('can returns false for ai_predictions on free tier', function (): void {
$plan = Plan::where('name', 'free')->first(); $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 { it('can returns true for ai_predictions on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first(); $plan = Plan::where('name', 'plus')->first();
expect($plan->features['ai_predictions'])->toBeTrue(); expect($plan->ai_predictions)->toBeTrue();
}); });
// ─── PlanSeeder idempotency ─────────────────────────────────────────────────── // ─── PlanSeeder idempotency ───────────────────────────────────────────────────
it('PlanSeeder is idempotent', function (): void { it('PlanSeeder is idempotent', function (): void {
// Run seeder a second time
$this->artisan('db:seed', ['--class' => 'PlanSeeder']); $this->artisan('db:seed', ['--class' => 'PlanSeeder']);
expect(Plan::count())->toBe(4); expect(Plan::count())->toBe(4);
@@ -211,15 +206,15 @@ it('scopeForFuelType filters by fuel type', function (): void {
// ─── push frequency ─────────────────────────────────────────────────────────── // ─── push frequency ───────────────────────────────────────────────────────────
it('seeds push.frequency for every tier', function (): void { it('seeds push frequency for every tier', function (): void {
expect(Plan::where('name', 'free')->first()->features['push']) expect(Plan::where('name', 'free')->first())
->toBe(['enabled' => false, 'frequency' => 'none']) ->push_enabled->toBeFalse()->push_frequency->toBe('none')
->and(Plan::where('name', 'basic')->first()->features['push']) ->and(Plan::where('name', 'basic')->first())
->toBe(['enabled' => true, 'frequency' => 'daily']) ->push_enabled->toBeTrue()->push_frequency->toBe('daily')
->and(Plan::where('name', 'plus')->first()->features['push']) ->and(Plan::where('name', 'plus')->first())
->toBe(['enabled' => true, 'frequency' => 'triggered']) ->push_enabled->toBeTrue()->push_frequency->toBe('triggered')
->and(Plan::where('name', 'pro')->first()->features['push']) ->and(Plan::where('name', 'pro')->first())
->toBe(['enabled' => true, 'frequency' => 'triggered']); ->push_enabled->toBeTrue()->push_frequency->toBe('triggered');
}); });
// ─── display name ───────────────────────────────────────────────────────────── // ─── display name ─────────────────────────────────────────────────────────────

View File

@@ -43,12 +43,12 @@ it('saves email frequency on edit', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id]) Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([ ->fillForm([
'features.email.frequency' => 'daily', 'email_frequency' => 'daily',
]) ])
->call('save') ->call('save')
->assertHasNoFormErrors(); ->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 { 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]) Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([ ->fillForm([
'features.sms.daily_limit' => 3, 'sms_daily_limit' => 3,
]) ])
->call('save') ->call('save')
->assertHasNoFormErrors(); ->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 { 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]) Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([ ->fillForm([
'features.fuel_types.max' => null, 'max_fuel_types' => null,
]) ])
->call('save') ->call('save')
->assertHasNoFormErrors(); ->assertHasNoFormErrors();
expect($plan->fresh()->features['fuel_types']['max'])->toBeNull(); expect($plan->fresh()->max_fuel_types)->toBeNull();
}); });

View File

@@ -1,5 +1,6 @@
<?php <?php
use Database\Seeders\PlanSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@@ -16,6 +17,9 @@ use Tests\TestCase;
pest()->extend(TestCase::class) pest()->extend(TestCase::class)
->use(RefreshDatabase::class) ->use(RefreshDatabase::class)
->beforeEach(function (): void {
$this->seed(PlanSeeder::class);
})
->in('Feature', 'Unit'); ->in('Feature', 'Unit');
/* /*