Files
fuel-price/docs/tiers.md
Ovidiu U c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold
and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual)
and introduces the display-name layer from pricing-plan.md v2.

- PlanTier::label() + Plan::displayName() + PlanFeatures::displayName()
  expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay
  basic/plus/pro so every call site, Stripe mapping, and test keeps working.
- push.frequency key added to features JSON (none/daily/triggered), mirroring
  email.frequency so Daily's daily push is distinguishable from Smart/Pro's
  triggered push. Seeder, factory, free-tier stubs, and Filament form updated.
- Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge
  "Most Popular"→"Most pick this"; CTAs refreshed.
- docs/tiers.md change log records the full diff.

Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:57:24 +01:00

11 KiB

Tier & Entitlement System

FuelAlert has four tiers: free, basic, plus, pro. Every entitlement decision — which channels a user can receive, how often, and what features they can access — flows through a single PlanFeatures service. Nothing else makes entitlement decisions.


Tiers at a glance

Tier Price Email Push WhatsApp SMS AI predictions Price threshold Score alerts Fuel types
free £0 weekly digest 1
basic £0.99 daily daily daily 1
plus £2.49 triggered triggered triggered max 1/day 1
pro £3.99 triggered triggered triggered max 3/day unlimited

Tiers are stored in the plans table. The features JSON column defines every limit and flag. database/seeders/PlanSeeder.php is the source of truth — this table mirrors it.

Deeper entitlements (history window, prediction level, leaderboard size, saved stations, fuel log caps, brand comparison, route planner, family sharing) are defined in docs/superpowers/specs/2026-04-15-tier-features-design.md. That spec extends the features JSON shape with additional keys beyond the notification-channel flags below.


Key files

File Purpose
app/Enums/PlanTier.php Backed enum — always use PlanTier::Plus->value, never raw strings
app/Models/Plan.php Plan model with resolveForUser() and cache bust on save
app/Models/UserNotificationPreference.php Per-user channel opt-in/out
app/Models/NotificationLog.php Append-only send/miss log
app/Services/PlanFeatures.php Single entry point for all entitlement checks
app/Http/Middleware/RequiresFeature.php Route-level feature gates
app/Jobs/DispatchUserNotificationJob.php Sends notifications and logs every outcome
app/Jobs/SendScheduledWhatsAppJob.php Fan-out job for scheduled morning/evening updates
database/seeders/PlanSeeder.php Idempotent seeder — run after deploy

The plans table

id                          bigint PK
name                        string          — free | basic | plus | pro
stripe_price_id_monthly     string nullable — Cashier price ID for monthly billing
stripe_price_id_annual      string nullable — Cashier price ID for annual billing
features                    json            — see shape below
active                      boolean
timestamps

features JSON shape (notification-channel flags)

{
  "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": 3 },
  "ai_predictions": true,
  "price_threshold": true,
  "score_alerts": true
}

fuel_types.max: null means unlimited (pro only). email.frequency values: weekly_digest, daily, triggered. push.frequency values: none (when disabled), daily, triggered. whatsapp and sms always carry daily_limit (and whatsapp carries scheduled_updates) even when enabled: false — set to 0 on disabled tiers. See PlanSeeder. Boolean features default to false on the free tier.

price_threshold and score_alerts are enabled on basic and above (not plus-only). ai_predictions is plus and above only.


Resolving the plan for a user

Plan::resolveForUser(User $user) maps a user's active Cashier subscription to a plan row, falling back to the free plan if no active subscription exists. The result is cached for 1 hour.

// Resolve the plan — cached automatically
$plan = Plan::resolveForUser($user);

echo $plan->name;                             // 'plus'
echo $plan->features['sms']['daily_limit'];   // 3

The cache is tagged plans and flushed whenever a Plan row is saved. Use Cache::supportsTags() guard if your driver (e.g. file/database) doesn't support tagging.


Checking entitlements — PlanFeatures

Always use PlanFeatures::for($user) — never query notification_log directly for limit checks, and never hardcode tier names in jobs or controllers.

$features = PlanFeatures::for($user);

// Does the tier allow this channel at all?
$features->canUseChannel('sms');              // bool

// Tier allows AND daily limit not yet hit?
$features->canSendNow('sms');                 // bool

// All channels passing: tier allows → user enabled → limit not hit
$features->channelsFor('price_threshold');    // string[] e.g. ['email', 'push']

// Fuel type tracking
$features->canTrackFuelType('E10');           // bool
$features->fuelTypeLimit();                   // int|null (null = unlimited)
$features->trackedFuelTypeCount();            // int

// Boolean feature flags
$features->can('ai_predictions');             // bool

// Missed notification counts (for dashboard / digest emails)
$features->missedToday('sms');                // int
$features->missedThisMonth('sms');            // int

// Resolved tier name
$features->tier();                            // 'free' | 'basic' | 'plus' | 'pro'

PlanFeatures never throws — if plan resolution fails it falls back to a free-tier stub.


Route-level feature gates

Register the feature middleware alias in bootstrap/app.php, then use it on any route:

// bootstrap/app.php
$middleware->alias(['feature' => RequiresFeature::class]);

// routes/web.php or routes/api.php
Route::get('/predictions', PredictionsController::class)
    ->middleware('feature:ai_predictions');

Returns 403 JSON when the feature is not available:

{ "error": "upgrade_required", "feature": "ai_predictions" }

Use this for route-level gates only. Channel-level logic stays in DispatchUserNotificationJob.


Dispatching notifications

Triggered (price update, score change)

// Dispatched by ProcessPriceAlerts or similar upstream job
DispatchUserNotificationJob::dispatch($user, 'price_threshold', 'E10', price: 143.9);

The job resolves channels, sends (stubbed until FuelPriceAlert notification exists), and logs every outcome:

Outcome sent missed_reason
Sent true null
Tier allows, limit hit false daily_limit
Tier blocks channel user wanted false tier_restricted
User deliberately disabled channel not logged

Scheduled WhatsApp (morning / evening)

// routes/console.php
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();

SendScheduledWhatsAppJob finds users whose plan has whatsapp.scheduled_updates > 0, whose whatsapp preference is enabled, and who have not hit their daily limit. It then dispatches DispatchUserNotificationJob per user with trigger type scheduled_morning or scheduled_evening.


Adding a new feature flag

  1. Add the key to the features JSON in PlanSeeder for each tier.
  2. Add a method to PlanFeatures (e.g. canExportData(): bool).
  3. Update the Filament PlanResource form (app/Filament/Resources/Plans/Schemas/PlanForm.php).
  4. Add a test in tests/Feature/Tiers/.

Adding a new notification channel

  1. Add the channel key to the features JSON shape in PlanSeeder.
  2. Add enabled and daily_limit sub-keys following the existing pattern.
  3. Add the channel to DispatchUserNotificationJob::ALL_CHANNELS.
  4. Update PlanFeatures::channelsFor() if any trigger-type filtering is needed.
  5. Add tests.

Testing

Seed the four plan rows before each test:

beforeEach(function (): void {
    $this->artisan('db:seed', ['--class' => 'PlanSeeder']);
});

Use Queue::fake() when asserting dispatch behaviour:

Queue::fake();

DispatchUserNotificationJob::dispatch($user, 'price_threshold', 'E10');

Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);

Test files live in tests/Feature/Tiers/:

File Covers
PlanFeaturesTest.php canUseChannel, canSendNow, canTrackFuelType, can(), middleware, log scopes, display name, push.frequency shape
PlanResourceTest.php Filament list/edit, no create/delete, saves features correctly
DispatchUserNotificationJobTest.php Sent logging, tier_restricted, daily_limit, user-disabled suppression, queue name, fan-out

Unit:

File Covers
tests/Unit/Enums/PlanTierTest.php PlanTier::label() — user-facing display names

Change log

2026-04-20 — display-name layer, push.frequency, pricing card rename

Reconciled docs with PlanSeeder reality and introduced the display-name layer from Downloads/pricing-plan.md v2.

Entitlement reality (docs were stale before this pass):

  • price_threshold and score_alerts are on basic, plus, pro — not plus-only.
  • ai_predictions is plus+pro only.
  • Schema columns are stripe_price_id_monthly and stripe_price_id_annual (not a single stripe_price_id).

New: display-name layer. Backend tier identifiers stay basic/plus/pro; UI-facing names are Free/Daily/Smart/Pro.

  • app/Enums/PlanTier.php — added label(): string
  • app/Models/Plan.php — added displayName(): string (delegates to enum)
  • app/Services/PlanFeatures.php — added displayName(): string

New: push.frequency key in features JSON. Mirrors email.frequency so Daily's "daily push" is distinguishable from Smart/Pro's "triggered push".

  • Values: none (when disabled), daily, triggered
  • Seeded: free=none, basic=daily, plus=triggered, pro=triggered
  • Touched: PlanSeeder, PlanFactory, free-tier stubs in Plan::resolveForUser + PlanFeatures::__construct, Filament PlanForm

Marketing: homepage pricing cards renamed. resources/js/views/Home.vue:

  • Card labels: BasicDaily, PlusSmart
  • Badge: Most PopularMost pick this
  • CTAs: Select BasicChoose Daily, Join PlusChoose Smart, Go ProChoose Pro, free unauthed Get startedStart free
  • Smart retains the existing accent-ring highlight; Pro retains the dark card.

Deferred: Fleet tier (per-seat B2B), Start 14-day trial CTA on Smart (no trial backend), swapping Smart to a dark card (current accent ring is sufficient).

Operational: existing DB rows won't have push.frequency until php artisan db:seed --class=PlanSeeder runs. The seeder is idempotent.