Files
fuel-price/docs/tiers.md
Ovidiu U 4220b1b86a
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add subscription tiers, notification preferences, and logging infrastructure
- Add database migrations for plans, subscriptions, notification preferences, and notification log tables
- Implement DispatchUserNotificationJob to handle channel resolution, daily limits, and logging (sent/tier_restricted/daily_limit)
- Add SendScheduledWhatsAppJob for scheduled notification delivery
- Create PlanFeatures service to resolve tier capabilities, check daily limits, and validate fuel
2026-04-14 16:20:51 +01:00

7.7 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 Fuel types
free £0 weekly digest 1
basic £0.99 daily daily daily 1
plus £2.49 triggered triggered triggered max 1/day yes 1
pro £3.99 triggered triggered triggered max 3/day yes unlimited

Tiers are stored in the plans table. The features JSON column defines every limit and flag.


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     string nullable — matches Cashier's stripe_price column
features            json            — see shape below
active              boolean
timestamps

features JSON shape

{
  "fuel_types": { "max": 1 },
  "email":      { "enabled": true, "frequency": "triggered" },
  "push":       { "enabled": true },
  "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. Boolean features default to false on the free tier.


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
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