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>
410 lines
13 KiB
Markdown
410 lines
13 KiB
Markdown
# 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 |
|
|
|-----------|---------------|--------------|--------------|--------------|
|
|
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
|
|
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
|
| whatsapp | ✗ | ✓ 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`:
|
|
|
|
```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_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 default true
|
|
timestamps
|
|
```
|
|
|
|
**`features` JSON shape:**
|
|
|
|
```json
|
|
{
|
|
"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. `email.frequency` values: `weekly_digest`,
|
|
`daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`,
|
|
`triggered`. All boolean features default `false` on free.
|
|
|
|
`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality:
|
|
|
|
- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only).
|
|
- `ai_predictions` is **plus and pro only**.
|
|
- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`)
|
|
even when `enabled: false` — set to `0` on disabled tiers.
|
|
|
|
> Deeper per-tier feature flags (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
|
|
> is the source of truth for entitlements beyond notification channels.
|
|
|
|
### `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 `features` to `array`.
|
|
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
|
active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
|
|
or `stripe_price_id_annual`, falls back to the `free` plan row.
|
|
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
|
|
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
|
|
|
|
### `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`.
|
|
|
|
```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:**
|
|
- `canSendNow` always checks both the plan cap AND the live `notification_log`
|
|
count 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 query `notification_log` directly for limit checks.
|
|
|
|
---
|
|
|
|
## `RequiresFeature` Middleware
|
|
|
|
Registered as `feature` in `bootstrap/app.php`.
|
|
|
|
```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:
|
|
|
|
```php
|
|
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
|
|
php artisan db:seed --class=PlanSeeder
|
|
```
|
|
|
|
---
|
|
|
|
## Testing Expectations
|
|
|
|
Every entitlement check must have a Pest feature test:
|
|
|
|
- `canUseChannel` returns false when tier doesn't allow it
|
|
- `canSendNow` returns false when daily limit is reached
|
|
- `channelsFor` returns correct filtered list for each tier
|
|
- `canTrackFuelType` enforces max correctly, null = unlimited
|
|
- Middleware returns 403 with correct JSON for missing feature
|
|
- `DispatchUserNotification` job logs missed_reason correctly
|
|
- `PlanSeeder` is 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_log` for limit checks outside `PlanFeatures`
|
|
- Never hardcode tier names as strings outside `Plan::TIERS` constant or an Enum
|
|
- Never send a notification without logging it
|
|
- Never bypass `PlanFeatures` in a job or controller "just this once"
|
|
- Never allow the `features` JSON to be partially saved — always merge full shape
|
|
- Never add a new feature to the JSON without adding a corresponding method to
|
|
`PlanFeatures` and updating the `PlanSeeder`
|