- 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
395 lines
12 KiB
Markdown
395 lines
12 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 string nullable — maps Cashier price to this plan
|
|
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
|
|
},
|
|
"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 `features` to `array`.
|
|
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
|
active Cashier subscription price ID, matches to `stripe_price_id`, 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`
|