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:
@@ -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'),
|
||||
]),
|
||||
]);
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user