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