- 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
12 KiB
Tier & Entitlement System — Claude Code Rules
Overview
FuelAlert uses a plan-based entitlement system. Every decision about what a user
can receive, on which channel, at what frequency, is resolved through a single
PlanFeatures service. Nothing else makes entitlement decisions.
Users subscribe via Stripe (Laravel Cashier). The active plan is resolved from
the Stripe subscription's price ID, mapped to a Plan model row.
Tiers
| Tier | Price | Stripe Price Env Key |
|---|---|---|
| free | £0 | — |
| basic | £0.99 | STRIPE_PRICE_BASIC |
| plus | £2.49 | STRIPE_PRICE_PLUS |
| pro | £3.99 | STRIPE_PRICE_PRO |
A user with no active Cashier subscription is always resolved as free.
Fuel Types
Six fuel types exist across the app:
E10, E5, B7_STANDARD, B7_PREMIUM, B10, HVO
All six are available to all tiers. The restriction is quantity only:
| Tier | Max tracked fuel types |
|---|---|
| free | 1 |
| basic | 1 |
| plus | 1 |
| pro | unlimited (null) |
Notification Channels
| Channel | free | basic | plus | pro |
|---|---|---|---|---|
| weekly digest | daily | ✓ triggered | ✓ triggered | |
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| ✗ | ✓ daily | ✓ triggered | ✓ triggered | |
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day |
WhatsApp also supports scheduled updates (morning + evening) independent of price triggers — available to any tier that has WhatsApp enabled.
Channel daily limits (sms_daily_limit, whatsapp_daily_limit) are enforced
by counting rows in notification_log for (user_id, channel, DATE(created_at)).
Notification Triggers
| Trigger | Description |
|---|---|
price_threshold |
Price drops at or below the user's saved threshold |
score_change |
Fill-up score flips good↔bad for a fuel type |
scheduled_morning |
WhatsApp scheduled update — fired by scheduler |
scheduled_evening |
WhatsApp scheduled update — fired by scheduler |
Enum
Use a PlanTier backed enum at app/Enums/PlanTier.php:
enum PlanTier: string
{
case Free = 'free';
case Basic = 'basic';
case Plus = 'plus';
case Pro = 'pro';
}
Reference PlanTier::Free->value everywhere, never raw strings.
Stripe Price IDs
Each tier has two prices:
| Tier | Monthly Env Key | Annual Env Key |
|---|---|---|
| basic | STRIPE_PRICE_BASIC_MONTHLY |
STRIPE_PRICE_BASIC_ANNUAL |
| plus | STRIPE_PRICE_PLUS_MONTHLY |
STRIPE_PRICE_PLUS_ANNUAL |
| pro | STRIPE_PRICE_PRO_MONTHLY |
STRIPE_PRICE_PRO_ANNUAL |
Database Schema
plans table
id unsignedBigInteger PK
name string — free | basic | plus | pro
stripe_price_id string nullable — maps Cashier price to this plan
features json — see shape below
active boolean default true
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. email.frequency values: weekly_digest,
daily, triggered. All boolean features default false on free.
user_notification_preferences table
id unsignedBigInteger PK
user_id unsignedBigInteger FK users.id cascadeDelete
channel string — email | push | whatsapp | sms
fuel_type string — E10 | E5 | B7_STANDARD | B7_PREMIUM | B10 | HVO
enabled boolean default true
timestamps
unique([user_id, channel, fuel_type])
The user opts channels in/out here. The tier is the ceiling — if the plan does not allow a channel, this preference is ignored regardless of its value.
notification_log table
id unsignedBigInteger PK
user_id unsignedBigInteger FK users.id cascadeDelete
channel string
trigger_type string — price_threshold | score_change | scheduled_morning | scheduled_evening
fuel_type string
price decimal(8,3) nullable
sent boolean
missed_reason string nullable — daily_limit | tier_restricted | user_disabled
created_at timestamp
No updated_at — this is append-only. Index on (user_id, channel, created_at)
for daily limit queries. Index on (user_id, sent, created_at) for dashboard
missed-count queries.
Models
Plan
- Casts
featurestoarray. - Has a static
resolveForUser(User $user): Planmethod — looks up the user's active Cashier subscription price ID, matches tostripe_price_id, falls back to thefreeplan row. - Cache the resolved plan:
Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...). - Bust
Cache::tags(['plans'])in an Eloquentsavedobserver onPlan.
UserNotificationPreference
belongsTo(User::class)- Scope:
scopeEnabled($query)— where enabled = true - Scope:
scopeForChannel($query, string $channel) - Scope:
scopeForFuelType($query, string $fuelType)
NotificationLog
belongsTo(User::class)- Scope:
scopeSentToday($query, string $channel)— counts today's sent rows - Scope:
scopeMissed($query)— where sent = false - Never update rows — only insert.
PlanFeatures Service
Located at app/Services/PlanFeatures.php.
PlanFeatures::for($user)->canUseChannel('sms') // bool — tier allows it
PlanFeatures::for($user)->canSendNow('sms') // bool — tier allows + under daily limit
PlanFeatures::for($user)->channelsFor('price_threshold') // string[] — allowed + user-enabled + under limit
PlanFeatures::for($user)->canTrackFuelType(string $type) // bool — under max
PlanFeatures::for($user)->trackedFuelTypeCount() // int
PlanFeatures::for($user)->fuelTypeLimit() // int|null
PlanFeatures::for($user)->can('ai_predictions') // bool — generic feature flag
PlanFeatures::for($user)->missedToday(string $channel) // int — for dashboard
PlanFeatures::for($user)->missedThisMonth(string $channel)// int — for digest email
PlanFeatures::for($user)->tier() // string — free|basic|plus|pro
Rules:
canSendNowalways checks both the plan cap AND the livenotification_logcount for today. Never skip either check.channelsFor($triggerType)is the method used by the dispatch job. It returns only channels that pass: tier allows → user has enabled → daily limit not hit.- The service must never throw. If the plan cannot be resolved, treat as
free. - The service is the only place daily limit logic lives. Jobs and controllers
call
PlanFeatures, never querynotification_logdirectly for limit checks.
RequiresFeature Middleware
Registered as feature in bootstrap/app.php.
// Usage in routes
Route::get('/predictions', PredictionsController::class)
->middleware('feature:ai_predictions');
Returns 403 { "error": "upgrade_required", "feature": "ai_predictions" } if
PlanFeatures::for($request->user())->can($feature) is false.
Only use for route-level feature gates. Channel-level logic stays in the job.
Notification Dispatch Flow
Price update (every 15 min)
PriceUpdated event (fired by polling job)
└── ProcessPriceAlerts job (queued, single instance via WithoutOverlapping)
├── Find users whose threshold >= new price for this fuel type
├── Find users subscribed to score_change if score flipped
├── Chunk users → dispatch DispatchUserNotification job per user
DispatchUserNotification job
1. Load plan via Plan::resolveForUser($user) — cached
2. Instantiate PlanFeatures::for($user)
3. $channels = $features->channelsFor($triggerType) — filtered list
4. foreach $channels as $channel:
a. Send via the appropriate Laravel Notification class
b. Log to notification_log (sent: true)
5. foreach skipped channels (tier allows but limit hit):
a. Log to notification_log (sent: false, missed_reason: daily_limit)
6. foreach tier-blocked channels the user had enabled in prefs:
a. Log to notification_log (sent: false, missed_reason: tier_restricted)
Do not log channels the user has manually disabled (user_disabled would be
noise — those are intentional).
Scheduled WhatsApp updates
Two scheduler entries:
Schedule::job(SendScheduledWhatsApp::class, 'morning')->dailyAt('07:30');
Schedule::job(SendScheduledWhatsApp::class, 'evening')->dailyAt('18:00');
SendScheduledWhatsApp queries all users where:
- Plan has
whatsapp.scheduled_updates > 0 - User has whatsapp preference enabled
canSendNow('whatsapp')is true at dispatch time
Same logging rules apply.
Filament PlanResource
Located in the admin panel. Edits the features JSON column using explicit form
fields — never a raw key-value editor.
Form fields:
Section: Fuel Types
- NumberInput fuel_types.max (null = unlimited, label: "Max fuel types — leave blank for unlimited")
Section: Email
- Toggle email.enabled
- Select email.frequency options: weekly_digest | daily | triggered
Section: Push
- Toggle push.enabled
Section: WhatsApp
- Toggle whatsapp.enabled
- NumberInput whatsapp.daily_limit
- NumberInput whatsapp.scheduled_updates
Section: SMS
- Toggle sms.enabled
- NumberInput sms.daily_limit
Section: Features
- Toggle ai_predictions
- Toggle price_threshold
- Toggle score_alerts
On save, bust Cache::tags(['plans']).
Do not allow deleting plan rows — disable the DeleteAction on the resource.
Do not allow creating new plan rows from the UI — the four tiers are seeded.
Filament Dashboard Widget — Missed Notifications
A StatsOverviewWidget on the user detail page (or a standalone widget) showing:
SMS missed today: 3 [Upgrade to Pro]
WhatsApp missed today: 0
Total missed this month: 12
Data sourced from NotificationLog::scopeMissed() queries. This data also feeds
the weekly/monthly digest email — the mailable receives the counts and renders
a "you missed X alerts — upgrade" block.
Seeder
A PlanSeeder must exist that creates or updates all four plan rows with correct
default feature values. It must be idempotent (updateOrCreate on name).
Run as part of DatabaseSeeder in production-safe seeders.
php artisan db:seed --class=PlanSeeder
Testing Expectations
Every entitlement check must have a Pest feature test:
canUseChannelreturns false when tier doesn't allow itcanSendNowreturns false when daily limit is reachedchannelsForreturns correct filtered list for each tiercanTrackFuelTypeenforces max correctly, null = unlimited- Middleware returns 403 with correct JSON for missing feature
DispatchUserNotificationjob logs missed_reason correctlyPlanSeederis idempotent
Use factories for Plan, User, UserNotificationPreference, NotificationLog.
The Plan factory should accept a tier state: Plan::factory()->pro()->create().
What Must Never Happen
- Never query
notification_logfor limit checks outsidePlanFeatures - Never hardcode tier names as strings outside
Plan::TIERSconstant or an Enum - Never send a notification without logging it
- Never bypass
PlanFeaturesin a job or controller "just this once" - Never allow the
featuresJSON to be partially saved — always merge full shape - Never add a new feature to the JSON without adding a corresponding method to
PlanFeaturesand updating thePlanSeeder