Files
fuel-price/.claude/rules/tiers.md
Ovidiu U c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
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>
2026-04-20 18:57:24 +01:00

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`