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
This commit is contained in:
394
.claude/rules/tiers.md
Normal file
394
.claude/rules/tiers.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user