# 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`
