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