Consolidate pricing onto a single source. Pro is deferred from launch (left dormant: no Stripe price, no card), so the offered set is 3 tiers. - Extract the pricing grid and footer into shared components (PricingGrid.vue, landing/SiteFooter.vue); add a /pricing route rendering Pricing.vue; remove the pricing section from Home - Repoint every upgrade link to the /pricing route (LandingNav and SiteFooter via RouterLink, UpsellBanner CTA) — no more #pricing anchors - Bump Smart (plus) SMS daily limit 1 -> 3 (PlanSeeder + PlanFactory), update PlanFeaturesTest assertion - Rewrite /pricing card bullets to match real entitlements (drop unbuilt promises: multi-location tracking, 14-day trend, supermarket anchor) - Fix stale "1/day" SMS references in notifications.md, tiers.md, docs/tiers.md - Delete unused resources/views/components/pricing-card.blade.php Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
234 lines
9.8 KiB
PHP
234 lines
9.8 KiB
PHP
<?php
|
|
|
|
use App\Enums\FuelType;
|
|
use App\Http\Middleware\RequiresFeature;
|
|
use App\Models\NotificationLog;
|
|
use App\Models\Plan;
|
|
use App\Models\User;
|
|
use App\Models\UserNotificationPreference;
|
|
use App\Services\PlanFeatures;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Request;
|
|
|
|
uses(RefreshDatabase::class);
|
|
|
|
beforeEach(function (): void {
|
|
// Seed all four plan rows before each test
|
|
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
|
});
|
|
|
|
// ─── canUseChannel ────────────────────────────────────────────────────────────
|
|
|
|
it('canUseChannel returns false for sms on free tier', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
expect(PlanFeatures::for($user)->canUseChannel('sms'))->toBeFalse();
|
|
});
|
|
|
|
it('canUseChannel returns false for sms on basic tier', function (): void {
|
|
$plan = Plan::where('name', 'basic')->first();
|
|
|
|
expect($plan->sms_enabled)->toBeFalse();
|
|
});
|
|
|
|
it('canUseChannel returns true for sms on plus tier', function (): void {
|
|
$plan = Plan::where('name', 'plus')->first();
|
|
|
|
expect($plan->sms_enabled)->toBeTrue();
|
|
});
|
|
|
|
it('canUseChannel returns true for sms on pro tier', function (): void {
|
|
$plan = Plan::where('name', 'pro')->first();
|
|
|
|
expect($plan->sms_enabled)->toBeTrue();
|
|
});
|
|
|
|
// ─── canSendNow ───────────────────────────────────────────────────────────────
|
|
|
|
it('canSendNow returns false when tier does not allow the channel', function (): void {
|
|
$user = User::factory()->create();
|
|
// free tier: push = false
|
|
|
|
expect(PlanFeatures::for($user)->canSendNow('push'))->toBeFalse();
|
|
});
|
|
|
|
it('canSendNow returns false when daily limit is reached', function (): void {
|
|
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 3
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
NotificationLog::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'sms',
|
|
'sent' => true,
|
|
'created_at' => now(),
|
|
]);
|
|
|
|
expect($plan->sms_daily_limit)->toBe(3);
|
|
|
|
$sentCount = NotificationLog::where('user_id', $user->id)
|
|
->where('channel', 'sms')
|
|
->where('sent', true)
|
|
->whereDate('created_at', today())
|
|
->count();
|
|
|
|
expect($sentCount)->toBe(1);
|
|
});
|
|
|
|
// ─── canTrackFuelType ─────────────────────────────────────────────────────────
|
|
|
|
it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
|
|
$plan = Plan::where('name', 'basic')->first(); // max_fuel_types = 1
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create([
|
|
'user_id' => $user->id,
|
|
'channel' => 'email',
|
|
'fuel_type' => FuelType::E10->value,
|
|
'enabled' => true,
|
|
]);
|
|
|
|
expect($plan->max_fuel_types)->toBe(1);
|
|
|
|
$count = UserNotificationPreference::where('user_id', $user->id)
|
|
->distinct('fuel_type')
|
|
->count('fuel_type');
|
|
|
|
expect($count)->toBe(1);
|
|
});
|
|
|
|
it('pro tier has null fuel type limit meaning unlimited', function (): void {
|
|
$plan = Plan::where('name', 'pro')->first();
|
|
|
|
expect($plan->max_fuel_types)->toBeNull();
|
|
});
|
|
|
|
// ─── can() feature flags ──────────────────────────────────────────────────────
|
|
|
|
it('can returns false for ai_predictions on free tier', function (): void {
|
|
$plan = Plan::where('name', 'free')->first();
|
|
|
|
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->ai_predictions)->toBeTrue();
|
|
});
|
|
|
|
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
|
|
|
|
it('PlanSeeder is idempotent', function (): void {
|
|
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
|
|
|
|
expect(Plan::count())->toBe(4);
|
|
expect(Plan::where('name', 'free')->count())->toBe(1);
|
|
expect(Plan::where('name', 'basic')->count())->toBe(1);
|
|
expect(Plan::where('name', 'plus')->count())->toBe(1);
|
|
expect(Plan::where('name', 'pro')->count())->toBe(1);
|
|
});
|
|
|
|
// ─── RequiresFeature middleware ───────────────────────────────────────────────
|
|
|
|
it('RequiresFeature middleware returns 403 when feature is not available', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
$request = Request::create('/test', 'GET');
|
|
$request->setUserResolver(fn () => $user);
|
|
|
|
$middleware = new RequiresFeature;
|
|
$response = $middleware->handle($request, fn () => response('ok'), 'ai_predictions');
|
|
|
|
expect($response->getStatusCode())->toBe(403);
|
|
expect(json_decode((string) $response->getContent(), true))->toBe([
|
|
'error' => 'upgrade_required',
|
|
'feature' => 'ai_predictions',
|
|
]);
|
|
});
|
|
|
|
// ─── NotificationLog scopes ───────────────────────────────────────────────────
|
|
|
|
it('scopeMissed returns only unsent log entries', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => true]);
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'daily_limit']);
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'tier_restricted']);
|
|
|
|
expect(NotificationLog::missed()->where('user_id', $user->id)->count())->toBe(2);
|
|
});
|
|
|
|
it('scopeSentToday returns only sent entries for that channel today', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()]);
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()->subDay()]);
|
|
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'sent' => true, 'created_at' => now()]);
|
|
|
|
expect(NotificationLog::sentToday('sms')->where('user_id', $user->id)->count())->toBe(1);
|
|
});
|
|
|
|
// ─── UserNotificationPreference scopes ───────────────────────────────────────
|
|
|
|
it('scopeEnabled filters disabled preferences', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => FuelType::E10->value, 'enabled' => true]);
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => FuelType::E10->value, 'enabled' => false]);
|
|
|
|
expect(UserNotificationPreference::enabled()->where('user_id', $user->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('scopeForChannel filters by channel', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms']);
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email']);
|
|
|
|
expect(UserNotificationPreference::forChannel('sms')->where('user_id', $user->id)->count())->toBe(1);
|
|
});
|
|
|
|
it('scopeForFuelType filters by fuel type', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E10->value]);
|
|
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E5->value]);
|
|
|
|
expect(UserNotificationPreference::forFuelType(FuelType::E10->value)->where('user_id', $user->id)->count())->toBe(1);
|
|
});
|
|
|
|
// ─── push frequency ───────────────────────────────────────────────────────────
|
|
|
|
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 ─────────────────────────────────────────────────────────────
|
|
|
|
it('Plan::displayName returns the user-facing label for each seeded tier', function (): void {
|
|
expect(Plan::where('name', 'free')->first()->displayName())->toBe('Free')
|
|
->and(Plan::where('name', 'basic')->first()->displayName())->toBe('Daily')
|
|
->and(Plan::where('name', 'plus')->first()->displayName())->toBe('Smart')
|
|
->and(Plan::where('name', 'pro')->first()->displayName())->toBe('Pro');
|
|
});
|
|
|
|
it('PlanFeatures::displayName delegates to the resolved tier', function (): void {
|
|
$user = User::factory()->create();
|
|
|
|
expect(PlanFeatures::for($user)->displayName())->toBe('Free');
|
|
});
|