Files
fuel-price/.claude/rules/tiers.md
Ovidiu U 4220b1b86a
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
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
2026-04-14 16:20:51 +01:00

12 KiB

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:

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:

{
  "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.

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.

// 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:

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