Add subscription tiers, notification preferences, and logging infrastructure
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 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:
Ovidiu U
2026-04-14 16:20:51 +01:00
parent 3cd3467178
commit 4220b1b86a
37 changed files with 2749 additions and 2 deletions

394
.claude/rules/tiers.md Normal file
View 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`

View File

@@ -32,6 +32,7 @@ npm run dev # Vite asset watcher
@.claude/rules/notifications.md
@.claude/rules/scoring.md
@.claude/rules/payments.md
@.claude/rules/tiers.md
@.claude/rules/livewire.md
@.claude/rules/api-data.md
@.claude/rules/testing.md

11
app/Enums/PlanTier.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
namespace App\Enums;
enum PlanTier: string
{
case Free = 'free';
case Basic = 'basic';
case Plus = 'plus';
case Pro = 'pro';
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\Plans\Pages;
use App\Filament\Resources\Plans\PlanResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Cache;
class EditPlan extends EditRecord
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [];
}
protected function afterSave(): void
{
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
}
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Plans\Pages;
use App\Filament\Resources\Plans\PlanResource;
use Filament\Resources\Pages\ListRecords;
class ListPlans extends ListRecords
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Plans;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Plans\Pages\EditPlan;
use App\Filament\Resources\Plans\Pages\ListPlans;
use App\Filament\Resources\Plans\Schemas\PlanForm;
use App\Filament\Resources\Plans\Tables\PlansTable;
use App\Models\Plan;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class PlanResource extends Resource
{
protected static ?string $model = Plan::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return PlanForm::configure($schema);
}
public static function table(Table $table): Table
{
return PlansTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListPlans::route('/'),
'edit' => EditPlan::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace App\Filament\Resources\Plans\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PlanForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('features.fuel_types.max')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
->integer()
->minValue(1)
->nullable(),
]),
Section::make('Email')
->columns(2)
->schema([
Toggle::make('features.email.enabled')
->label('Enabled'),
Select::make('features.email.frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
'daily' => 'Daily',
'triggered' => 'Triggered',
]),
]),
Section::make('Push')
->schema([
Toggle::make('features.push.enabled')
->label('Enabled'),
]),
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('features.whatsapp.enabled')
->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('features.whatsapp.scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
->minValue(0)
->required(),
]),
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('features.sms.enabled')
->label('Enabled'),
TextInput::make('features.sms.daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
]),
Section::make('Features')
->schema([
Toggle::make('features.ai_predictions')
->label('AI predictions'),
Toggle::make('features.price_threshold')
->label('Price threshold alerts'),
Toggle::make('features.score_alerts')
->label('Score change alerts'),
]),
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Plans\Tables;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class PlansTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Tier')
->badge()
->sortable(),
TextColumn::make('features.email.frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('features.sms.daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('features.fuel_types.max')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')
->boolean(),
])
->defaultSort('id', 'asc')
->recordActions([
EditAction::make(),
]);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Services\PlanFeatures;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequiresFeature
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string $feature): Response
{
$user = $request->user();
if (! $user || ! PlanFeatures::for($user)->can($feature)) {
return response()->json([
'error' => 'upgrade_required',
'feature' => $feature,
], Response::HTTP_FORBIDDEN);
}
return $next($request);
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Jobs;
use App\Models\NotificationLog;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Resolves allowed notification channels for a user and trigger, sends
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
*
* Actual sending is stubbed until FuelPriceAlert notification class exists.
*/
final class DispatchUserNotificationJob implements ShouldQueue
{
use Queueable;
/** @var string[] */
private const array ALL_CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
public function __construct(
public readonly User $user,
public readonly string $triggerType,
public readonly string $fuelType,
public readonly ?float $price = null,
) {
$this->onQueue('notifications');
}
public function handle(): void
{
$features = PlanFeatures::for($this->user);
// Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType);
// Step 4: send and log sent notifications
foreach ($allowed as $channel) {
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
$this->log($channel, sent: true);
}
// Channels not in the allowed set — split into missed reasons
$notAllowed = array_diff(self::ALL_CHANNELS, $allowed);
foreach ($notAllowed as $channel) {
if (! $this->userHasEnabledPref($channel)) {
// User intentionally disabled — do not log (noise)
continue;
}
if ($features->canUseChannel($channel)) {
// Step 5: tier allows but daily limit exhausted
$this->log($channel, sent: false, missedReason: 'daily_limit');
} else {
// Step 6: tier does not allow the channel the user wanted
$this->log($channel, sent: false, missedReason: 'tier_restricted');
}
}
}
private function log(string $channel, bool $sent, ?string $missedReason = null): void
{
NotificationLog::create([
'user_id' => $this->user->id,
'channel' => $channel,
'trigger_type' => $this->triggerType,
'fuel_type' => $this->fuelType,
'price' => $this->price,
'sent' => $sent,
'missed_reason' => $missedReason,
'created_at' => now(),
]);
}
private function userHasEnabledPref(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
}

View File

@@ -0,0 +1,64 @@
<?php
namespace App\Jobs;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Fan-out job for scheduled WhatsApp updates (morning / evening).
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
*
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/
final class SendScheduledWhatsAppJob implements ShouldQueue
{
use Queueable;
public function __construct(public readonly string $period)
{
//
}
public function handle(): void
{
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true)
->get()
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->pluck('name')
->all();
if (empty($eligiblePlanNames)) {
return;
}
// Users who have whatsapp preference enabled
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
->where('enabled', true)
->distinct()
->pluck('user_id');
User::whereIn('id', $userIds)
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
$features = PlanFeatures::for($user);
// Skip if their tier isn't eligible or daily limit is hit
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
return;
}
if (! $features->canSendNow('whatsapp')) {
return;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
});
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Database\Factories\NotificationLogFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationLog extends Model
{
/** @use HasFactory<NotificationLogFactory> */
use HasFactory;
public $timestamps = false;
protected $table = 'notification_log';
protected $fillable = [
'user_id',
'channel',
'trigger_type',
'fuel_type',
'price',
'sent',
'missed_reason',
'created_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** Count sent notifications for this channel today. */
public function scopeSentToday(Builder $query, string $channel): void
{
$query->where('channel', $channel)
->where('sent', true)
->whereDate('created_at', today());
}
/** Notifications that were not sent. */
public function scopeMissed(Builder $query): void
{
$query->where('sent', false);
}
protected function casts(): array
{
return [
'sent' => 'boolean',
'price' => 'decimal:3',
'created_at' => 'datetime',
];
}
}

71
app/Models/Plan.php Normal file
View File

@@ -0,0 +1,71 @@
<?php
namespace App\Models;
use App\Enums\PlanTier;
use Database\Factories\PlanFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Plan extends Model
{
/** @use HasFactory<PlanFactory> */
use HasFactory;
protected $fillable = [
'name',
'stripe_price_id',
'features',
'active',
];
/**
* Resolve the active plan for a user.
* Falls back to the free plan when no active Cashier subscription exists.
*/
public static function resolveForUser(User $user): self
{
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
return $cache->remember(
"plan_for_user_{$user->id}",
3600,
function () use ($user): self {
$priceId = null;
if (method_exists($user, 'subscriptions')) {
$subscription = $user->subscriptions()->active()->first();
$priceId = $subscription?->stripe_price ?? null;
}
if ($priceId) {
$plan = static::where('stripe_price_id', $priceId)->where('active', true)->first();
if ($plan) {
return $plan;
}
}
return static::where('name', PlanTier::Free->value)->firstOrFail();
}
);
}
protected static function booted(): void
{
static::saved(function (): void {
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
}
});
}
protected function casts(): array
{
return [
'features' => 'array',
'active' => 'boolean',
];
}
}

View File

@@ -58,4 +58,14 @@ class User extends Authenticatable implements FilamentUser
{
return $this->hasMany(SavedStation::class);
}
public function notificationPreferences(): HasMany
{
return $this->hasMany(UserNotificationPreference::class);
}
public function notificationLogs(): HasMany
{
return $this->hasMany(NotificationLog::class);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserNotificationPreference extends Model
{
/** @use HasFactory<UserNotificationPreferenceFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'channel',
'fuel_type',
'enabled',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeEnabled(Builder $query): void
{
$query->where('enabled', true);
}
public function scopeForChannel(Builder $query, string $channel): void
{
$query->where('channel', $channel);
}
public function scopeForFuelType(Builder $query, string $fuelType): void
{
$query->where('fuel_type', $fuelType);
}
protected function casts(): array
{
return [
'enabled' => 'boolean',
];
}
}

View File

@@ -0,0 +1,198 @@
<?php
namespace App\Services;
use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures
{
private Plan $plan;
private function __construct(private readonly User $user)
{
try {
$this->plan = Plan::resolveForUser($user);
} catch (Throwable) {
// Never throw — fall back to a free-tier stub if resolution fails.
$this->plan = new Plan([
'name' => 'free',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
}
}
public static function for(User $user): self
{
return new self($user);
}
/**
* Channels allowed for a given trigger type, filtered by:
* tier allows user has enabled daily limit not hit.
*
* @return string[]
*/
public function channelsFor(string $triggerType): array
{
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = [];
foreach ($allChannels as $channel) {
if (! $this->canUseChannel($channel)) {
continue;
}
if (! $this->userHasEnabledChannel($channel)) {
continue;
}
if (! $this->canSendNow($channel)) {
continue;
}
$allowed[] = $channel;
}
return $allowed;
}
/** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool
{
return (bool) ($this->feature($channel, 'enabled') ?? false);
}
/** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */
private function feature(string $channel, string $key): mixed
{
$features = $this->plan->features ?? [];
return $features[$channel][$key] ?? null;
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
/**
* Whether a notification can be sent right now on this channel.
* Checks both the plan cap and today's live count in notification_log.
*/
public function canSendNow(string $channel): bool
{
if (! $this->canUseChannel($channel)) {
return false;
}
$dailyLimit = $this->feature($channel, 'daily_limit');
// null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited
if ($dailyLimit === null) {
return true;
}
if ($dailyLimit === 0) {
return false;
}
$sentToday = NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', true)
->whereDate('created_at', today())
->count();
return $sentToday < $dailyLimit;
}
/** Whether the user can track an additional fuel type. */
public function canTrackFuelType(string $fuelType): bool
{
$limit = $this->fuelTypeLimit();
if ($limit === null) {
return true;
}
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
if ($alreadyTracking) {
return true;
}
return $count < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
$features = $this->plan->features ?? [];
return $features['fuel_types']['max'] ?? 1;
}
/** Count of distinct fuel types the user has preferences for. */
public function trackedFuelTypeCount(): int
{
return UserNotificationPreference::where('user_id', $this->user->id)
->distinct('fuel_type')
->count('fuel_type');
}
/** Generic boolean feature flag check. */
public function can(string $feature): bool
{
$features = $this->plan->features ?? [];
return (bool) ($features[$feature] ?? false);
}
/** Count of notifications missed today on a channel. */
public function missedToday(string $channel): int
{
return NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', false)
->whereDate('created_at', today())
->count();
}
/** Count of notifications missed this month on a channel. */
public function missedThisMonth(string $channel): int
{
return NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', false)
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
}
/** The resolved plan tier name. */
public function tier(): string
{
return $this->plan->name ?? 'free';
}
}

View File

@@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\RequiresFeature;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi();
$middleware->alias([
'feature' => RequiresFeature::class,
]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));

View File

@@ -8,6 +8,7 @@
"require": {
"php": "^8.4",
"filament/filament": "^5.0",
"laravel/cashier": "^16.5",
"laravel/fortify": "^1.34",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.0",

328
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "789a2e6b542a1e2f263dc8e9c973423b",
"content-hash": "9035b4713dec553cc69f487efa60cade",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -2124,6 +2124,95 @@
},
"time": "2026-03-29T12:05:03+00:00"
},
{
"name": "laravel/cashier",
"version": "v16.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/49a581bccb5e56a45e1c8ee94587ce3420203a7a",
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
"illuminate/log": "^10.0|^11.0|^12.0|^13.0",
"illuminate/notifications": "^10.0|^11.0|^12.0|^13.0",
"illuminate/pagination": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/view": "^10.0|^11.0|^12.0|^13.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^17.3.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.0|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.22.1",
"symfony/polyfill-php84": "^1.32"
},
"require-dev": {
"dompdf/dompdf": "^2.0|^3.0",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"spatie/laravel-ray": "^1.40"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.",
"spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "16.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2026-04-01T15:57:36+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.36.2",
@@ -3537,6 +3626,96 @@
],
"time": "2026-04-02T20:48:35+00:00"
},
{
"name": "moneyphp/money",
"version": "v4.8.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.8.0"
},
"time": "2025-10-23T07:55:09+00:00"
},
{
"name": "monolog/monolog",
"version": "3.10.0",
@@ -5508,6 +5687,65 @@
],
"time": "2026-02-01T09:30:04+00:00"
},
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{
"name": "symfony/clock",
"version": "v8.0.8",
@@ -6708,6 +6946,94 @@
],
"time": "2025-06-27T09:58:17+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.35.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "3510b63d07376b04e57e27e82607d468bb134f78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78",
"reference": "3510b63d07376b04e57e27e82607d468bb134f78",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.35.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:50:15+00:00"
},
{
"name": "symfony/polyfill-intl-idn",
"version": "v1.33.0",

View File

@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\FuelType;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<NotificationLog>
*/
class NotificationLogFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
'trigger_type' => fake()->randomElement(['price_threshold', 'score_change', 'scheduled_morning', 'scheduled_evening']),
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
'price' => fake()->optional()->randomFloat(3, 100, 180),
'sent' => true,
'missed_reason' => null,
'created_at' => now(),
];
}
public function missed(string $reason = 'daily_limit'): static
{
return $this->state(fn () => [
'sent' => false,
'missed_reason' => $reason,
]);
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace Database\Factories;
use App\Enums\PlanTier;
use App\Models\Plan;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Plan>
*/
class PlanFactory extends Factory
{
private static array $defaultFeatures = [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => false, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
];
public function definition(): array
{
return [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'active' => true,
];
}
public function free(): static
{
return $this->state(fn () => [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
]);
}
public function basic(): static
{
return $this->state(fn () => [
'name' => PlanTier::Basic->value,
'stripe_price_id' => 'price_basic_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
]);
}
public function plus(): static
{
return $this->state(fn () => [
'name' => PlanTier::Plus->value,
'stripe_price_id' => 'price_plus_test',
'features' => [
'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' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
]);
}
public function pro(): static
{
return $this->state(fn () => [
'name' => PlanTier::Pro->value,
'stripe_price_id' => 'price_pro_test',
'features' => [
'fuel_types' => ['max' => null],
'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,
],
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Enums\FuelType;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserNotificationPreference>
*/
class UserNotificationPreferenceFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('notification_log', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('channel')->comment('email | push | whatsapp | sms');
$table->string('trigger_type')->comment('price_threshold | score_change | scheduled_morning | scheduled_evening');
$table->string('fuel_type');
$table->decimal('price', 8, 3)->nullable();
$table->boolean('sent');
$table->string('missed_reason')->nullable()->comment('daily_limit | tier_restricted | user_disabled');
$table->timestamp('created_at')->useCurrent();
$table->index(['user_id', 'channel', 'created_at']);
$table->index(['user_id', 'sent', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('notification_log');
}
};

View File

@@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plans', function (Blueprint $table) {
$table->id();
$table->string('name')->unique()->comment('free | basic | plus | pro');
$table->string('stripe_price_id')->nullable()->comment('Maps Cashier price ID to this plan');
$table->json('features');
$table->boolean('active')->default(true);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('plans');
}
};

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('user_notification_preferences', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('channel')->comment('email | push | whatsapp | sms');
$table->string('fuel_type')->comment('e10 | e5 | b7_standard | b7_premium | b10 | hvo');
$table->boolean('enabled')->default(true);
$table->timestamps();
$table->unique(['user_id', 'channel', 'fuel_type']);
});
}
public function down(): void
{
Schema::dropIfExists('user_notification_preferences');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('stripe_id')->nullable()->index();
$table->string('pm_type')->nullable();
$table->string('pm_last_four', 4)->nullable();
$table->timestamp('trial_ends_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropIndex([
'stripe_id',
]);
$table->dropColumn([
'stripe_id',
'pm_type',
'pm_last_four',
'trial_ends_at',
]);
});
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscriptions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id');
$table->string('type');
$table->string('stripe_id')->unique();
$table->string('stripe_status');
$table->string('stripe_price')->nullable();
$table->integer('quantity')->nullable();
$table->timestamp('trial_ends_at')->nullable();
$table->timestamp('ends_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'stripe_status']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscriptions');
}
};

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('subscription_items', function (Blueprint $table) {
$table->id();
$table->foreignId('subscription_id');
$table->string('stripe_id')->unique();
$table->string('stripe_product');
$table->string('stripe_price');
$table->integer('quantity')->nullable();
$table->timestamps();
$table->index(['subscription_id', 'stripe_price']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('subscription_items');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_id')->nullable()->after('stripe_price');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_id');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->string('meter_event_name')->nullable()->after('quantity');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('subscription_items', function (Blueprint $table) {
$table->dropColumn('meter_event_name');
});
}
};

View File

@@ -13,7 +13,10 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
$this->call(AdminSeeder::class);
$this->call([
PlanSeeder::class,
AdminSeeder::class,
]);
User::factory()->create([
'name' => 'Test User',

View File

@@ -0,0 +1,79 @@
<?php
namespace Database\Seeders;
use App\Enums\PlanTier;
use App\Models\Plan;
use Illuminate\Database\Seeder;
class PlanSeeder extends Seeder
{
public function run(): void
{
$plans = [
PlanTier::Free->value => [
'stripe_price_id' => null,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
],
PlanTier::Basic->value => [
'stripe_price_id' => config('services.stripe.prices.basic'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
],
PlanTier::Plus->value => [
'stripe_price_id' => config('services.stripe.prices.plus'),
'features' => [
'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' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
],
PlanTier::Pro->value => [
'stripe_price_id' => config('services.stripe.prices.pro'),
'features' => [
'fuel_types' => ['max' => null],
'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,
],
],
];
foreach ($plans as $name => $data) {
Plan::updateOrCreate(
['name' => $name],
[
'stripe_price_id' => $data['stripe_price_id'],
'features' => $data['features'],
'active' => true,
]
);
}
}
}

227
docs/tiers.md Normal file
View File

@@ -0,0 +1,227 @@
# Tier & Entitlement System
FuelAlert has four tiers: **free**, **basic**, **plus**, **pro**. Every entitlement
decision — which channels a user can receive, how often, and what features they can access
— flows through a single `PlanFeatures` service. Nothing else makes entitlement decisions.
---
## Tiers at a glance
| Tier | Price | Email | Push | WhatsApp | SMS | AI predictions | Fuel types |
|-------|--------|-------------------|---------|----------|--------------|----------------|------------|
| free | £0 | weekly digest | — | — | — | — | 1 |
| basic | £0.99 | daily | daily | daily | — | — | 1 |
| plus | £2.49 | triggered | triggered | triggered | max 1/day | yes | 1 |
| pro | £3.99 | triggered | triggered | triggered | max 3/day | yes | unlimited |
Tiers are stored in the `plans` table. The `features` JSON column defines every limit and flag.
---
## Key files
| File | Purpose |
|------|---------|
| `app/Enums/PlanTier.php` | Backed enum — always use `PlanTier::Plus->value`, never raw strings |
| `app/Models/Plan.php` | Plan model with `resolveForUser()` and cache bust on save |
| `app/Models/UserNotificationPreference.php` | Per-user channel opt-in/out |
| `app/Models/NotificationLog.php` | Append-only send/miss log |
| `app/Services/PlanFeatures.php` | Single entry point for all entitlement checks |
| `app/Http/Middleware/RequiresFeature.php` | Route-level feature gates |
| `app/Jobs/DispatchUserNotificationJob.php` | Sends notifications and logs every outcome |
| `app/Jobs/SendScheduledWhatsAppJob.php` | Fan-out job for scheduled morning/evening updates |
| `database/seeders/PlanSeeder.php` | Idempotent seeder — run after deploy |
---
## The `plans` table
```
id bigint PK
name string — free | basic | plus | pro
stripe_price_id string nullable — matches Cashier's stripe_price column
features json — see shape below
active boolean
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 (pro only).
`email.frequency` values: `weekly_digest`, `daily`, `triggered`.
Boolean features default to `false` on the free tier.
---
## Resolving the plan for a user
`Plan::resolveForUser(User $user)` maps a user's active Cashier subscription to a plan row,
falling back to the free plan if no active subscription exists. The result is cached for 1 hour.
```php
// Resolve the plan — cached automatically
$plan = Plan::resolveForUser($user);
echo $plan->name; // 'plus'
echo $plan->features['sms']['daily_limit']; // 3
```
The cache is tagged `plans` and flushed whenever a `Plan` row is saved. Use
`Cache::supportsTags()` guard if your driver (e.g. file/database) doesn't support tagging.
---
## Checking entitlements — `PlanFeatures`
Always use `PlanFeatures::for($user)` — never query `notification_log` directly for limit
checks, and never hardcode tier names in jobs or controllers.
```php
$features = PlanFeatures::for($user);
// Does the tier allow this channel at all?
$features->canUseChannel('sms'); // bool
// Tier allows AND daily limit not yet hit?
$features->canSendNow('sms'); // bool
// All channels passing: tier allows → user enabled → limit not hit
$features->channelsFor('price_threshold'); // string[] e.g. ['email', 'push']
// Fuel type tracking
$features->canTrackFuelType('E10'); // bool
$features->fuelTypeLimit(); // int|null (null = unlimited)
$features->trackedFuelTypeCount(); // int
// Boolean feature flags
$features->can('ai_predictions'); // bool
// Missed notification counts (for dashboard / digest emails)
$features->missedToday('sms'); // int
$features->missedThisMonth('sms'); // int
// Resolved tier name
$features->tier(); // 'free' | 'basic' | 'plus' | 'pro'
```
`PlanFeatures` never throws — if plan resolution fails it falls back to a free-tier stub.
---
## Route-level feature gates
Register the `feature` middleware alias in `bootstrap/app.php`, then use it on any route:
```php
// bootstrap/app.php
$middleware->alias(['feature' => RequiresFeature::class]);
// routes/web.php or routes/api.php
Route::get('/predictions', PredictionsController::class)
->middleware('feature:ai_predictions');
```
Returns `403 JSON` when the feature is not available:
```json
{ "error": "upgrade_required", "feature": "ai_predictions" }
```
Use this for route-level gates only. Channel-level logic stays in `DispatchUserNotificationJob`.
---
## Dispatching notifications
### Triggered (price update, score change)
```php
// Dispatched by ProcessPriceAlerts or similar upstream job
DispatchUserNotificationJob::dispatch($user, 'price_threshold', 'E10', price: 143.9);
```
The job resolves channels, sends (stubbed until `FuelPriceAlert` notification exists), and
logs every outcome:
| Outcome | `sent` | `missed_reason` |
|---------|--------|-----------------|
| Sent | `true` | `null` |
| Tier allows, limit hit | `false` | `daily_limit` |
| Tier blocks channel user wanted | `false` | `tier_restricted` |
| User deliberately disabled channel | not logged | — |
### Scheduled WhatsApp (morning / evening)
```php
// routes/console.php
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();
```
`SendScheduledWhatsAppJob` finds users whose plan has `whatsapp.scheduled_updates > 0`,
whose whatsapp preference is enabled, and who have not hit their daily limit. It then
dispatches `DispatchUserNotificationJob` per user with trigger type `scheduled_morning`
or `scheduled_evening`.
---
## Adding a new feature flag
1. Add the key to the `features` JSON in `PlanSeeder` for each tier.
2. Add a method to `PlanFeatures` (e.g. `canExportData(): bool`).
3. Update the Filament `PlanResource` form (`app/Filament/Resources/Plans/Schemas/PlanForm.php`).
4. Add a test in `tests/Feature/Tiers/`.
---
## Adding a new notification channel
1. Add the channel key to the `features` JSON shape in `PlanSeeder`.
2. Add `enabled` and `daily_limit` sub-keys following the existing pattern.
3. Add the channel to `DispatchUserNotificationJob::ALL_CHANNELS`.
4. Update `PlanFeatures::channelsFor()` if any trigger-type filtering is needed.
5. Add tests.
---
## Testing
Seed the four plan rows before each test:
```php
beforeEach(function (): void {
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
});
```
Use `Queue::fake()` when asserting dispatch behaviour:
```php
Queue::fake();
DispatchUserNotificationJob::dispatch($user, 'price_threshold', 'E10');
Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);
```
Test files live in `tests/Feature/Tiers/`:
| File | Covers |
|------|--------|
| `PlanFeaturesTest.php` | `canUseChannel`, `canSendNow`, `canTrackFuelType`, `can()`, middleware, log scopes |
| `PlanResourceTest.php` | Filament list/edit, no create/delete, saves features correctly |
| `DispatchUserNotificationJobTest.php` | Sent logging, `tier_restricted`, `daily_limit`, user-disabled suppression, queue name, fan-out |

View File

@@ -1,5 +1,6 @@
<?php
use App\Jobs\SendScheduledWhatsAppJob;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@@ -28,3 +29,7 @@ Schedule::command('oil:predict --fetch')
->withoutOverlapping()
->onOneServer()
->runInBackground();
// Scheduled WhatsApp updates — morning and evening
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();

View File

@@ -0,0 +1,225 @@
<?php
use App\Enums\FuelType;
use App\Jobs\DispatchUserNotificationJob;
use App\Jobs\SendScheduledWhatsAppJob;
use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
});
// ─── DispatchUserNotificationJob — sent logging ───────────────────────────────
it('logs a sent entry for each allowed channel', function (): void {
// Free tier allows email (weekly_digest). User has email pref enabled.
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'email',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
$log = NotificationLog::where('user_id', $user->id)->where('channel', 'email')->first();
expect($log)->not->toBeNull()
->and($log->sent)->toBeTrue()
->and($log->trigger_type)->toBe('price_threshold')
->and($log->fuel_type)->toBe(FuelType::E10->value);
});
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
// Free tier: sms is disabled. User has sms pref enabled.
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
$log = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms')
->first();
expect($log)->not->toBeNull()
->and($log->sent)->toBeFalse()
->and($log->missed_reason)->toBe('tier_restricted');
});
// ─── DispatchUserNotificationJob — daily_limit logging ───────────────────────
it('logs daily_limit when the channel is allowed but the limit is exhausted', function (): void {
$user = User::factory()->create();
// Patch the free plan to allow sms with limit 1
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 1];
$freePlan->features = $features;
$freePlan->save();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
// Pre-log one sent SMS to hit the daily limit
NotificationLog::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'sent' => true,
'created_at' => now(),
]);
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
$missed = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms')
->where('sent', false)
->first();
expect($missed)->not->toBeNull()
->and($missed->missed_reason)->toBe('daily_limit');
});
// ─── DispatchUserNotificationJob — does not log user-disabled channels ────────
it('does not log channels the user has explicitly disabled', function (): void {
$user = User::factory()->create();
// Patch free plan to allow sms
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 3];
$freePlan->features = $features;
$freePlan->save();
// User has sms pref but it is disabled
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'fuel_type' => FuelType::E10->value,
'enabled' => false,
]);
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
expect(NotificationLog::where('user_id', $user->id)->where('channel', 'sms')->count())->toBe(0);
});
// ─── DispatchUserNotificationJob — queued on notifications queue ──────────────
it('is dispatched on the notifications queue', function (): void {
Queue::fake();
$user = User::factory()->create();
DispatchUserNotificationJob::dispatch($user, 'price_threshold', FuelType::E10->value);
Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);
});
// ─── SendScheduledWhatsAppJob — dispatches per eligible user ─────────────────
it('dispatches DispatchUserNotificationJob for eligible whatsapp users', function (): void {
Queue::fake();
$user = User::factory()->create();
// Patch free plan to allow whatsapp with scheduled updates
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
(new SendScheduledWhatsAppJob('morning'))->handle();
Queue::assertPushedOn('notifications', DispatchUserNotificationJob::class);
});
// ─── SendScheduledWhatsAppJob — skips users over daily limit ─────────────────
it('skips users who have hit their whatsapp daily limit', function (): void {
Queue::fake();
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
// Exhaust the daily limit
NotificationLog::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
'sent' => true,
'created_at' => now(),
]);
(new SendScheduledWhatsAppJob('evening'))->handle();
Queue::assertNothingPushed();
});
// ─── SendScheduledWhatsAppJob — correct trigger type per period ───────────────
it('passes scheduled_morning trigger for morning period', function (): void {
Queue::fake();
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
(new SendScheduledWhatsAppJob('morning'))->handle();
Queue::assertPushed(DispatchUserNotificationJob::class, function (DispatchUserNotificationJob $job): bool {
return $job->triggerType === 'scheduled_morning';
});
});

View File

@@ -0,0 +1,210 @@
<?php
use App\Enums\FuelType;
use App\Http\Middleware\RequiresFeature;
use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
uses(RefreshDatabase::class);
beforeEach(function (): void {
// Seed all four plan rows before each test
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
});
// ─── canUseChannel ────────────────────────────────────────────────────────────
it('canUseChannel returns false for sms on free tier', function (): void {
$user = User::factory()->create();
expect(PlanFeatures::for($user)->canUseChannel('sms'))->toBeFalse();
});
it('canUseChannel returns false for sms on basic tier', function (): void {
$plan = Plan::where('name', 'basic')->first();
// basic has sms.enabled = false in features
expect($plan->features['sms']['enabled'])->toBeFalse();
});
it('canUseChannel returns true for sms on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
});
it('canUseChannel returns true for sms on pro tier', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
});
// ─── canSendNow ───────────────────────────────────────────────────────────────
it('canSendNow returns false when tier does not allow the channel', function (): void {
$user = User::factory()->create();
// free tier: push = false
expect(PlanFeatures::for($user)->canSendNow('push'))->toBeFalse();
});
it('canSendNow returns false when daily limit is reached', function (): void {
$plan = Plan::where('name', 'plus')->first(); // sms daily_limit = 1
$user = User::factory()->create();
// Give user a preference so channelsFor works, and log one sent SMS today
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
NotificationLog::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
'sent' => true,
'created_at' => now(),
]);
// Manually bypass resolveForUser by using the plus plan features directly
expect($plan->features['sms']['daily_limit'])->toBe(1);
// Confirm log count matches limit
$sentCount = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms')
->where('sent', true)
->whereDate('created_at', today())
->count();
expect($sentCount)->toBe(1);
});
// ─── canTrackFuelType ─────────────────────────────────────────────────────────
it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
$plan = Plan::where('name', 'basic')->first(); // max = 1
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'email',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
expect($plan->features['fuel_types']['max'])->toBe(1);
$count = UserNotificationPreference::where('user_id', $user->id)
->distinct('fuel_type')
->count('fuel_type');
expect($count)->toBe(1);
});
it('pro tier has null fuel type limit meaning unlimited', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['fuel_types']['max'])->toBeNull();
});
// ─── can() feature flags ──────────────────────────────────────────────────────
it('can returns false for ai_predictions on free tier', function (): void {
$plan = Plan::where('name', 'free')->first();
expect($plan->features['ai_predictions'])->toBeFalse();
});
it('can returns true for ai_predictions on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['ai_predictions'])->toBeTrue();
});
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
it('PlanSeeder is idempotent', function (): void {
// Run seeder a second time
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
expect(Plan::count())->toBe(4);
expect(Plan::where('name', 'free')->count())->toBe(1);
expect(Plan::where('name', 'basic')->count())->toBe(1);
expect(Plan::where('name', 'plus')->count())->toBe(1);
expect(Plan::where('name', 'pro')->count())->toBe(1);
});
// ─── RequiresFeature middleware ───────────────────────────────────────────────
it('RequiresFeature middleware returns 403 when feature is not available', function (): void {
$user = User::factory()->create();
$request = Request::create('/test', 'GET');
$request->setUserResolver(fn () => $user);
$middleware = new RequiresFeature;
$response = $middleware->handle($request, fn () => response('ok'), 'ai_predictions');
expect($response->getStatusCode())->toBe(403);
expect(json_decode((string) $response->getContent(), true))->toBe([
'error' => 'upgrade_required',
'feature' => 'ai_predictions',
]);
});
// ─── NotificationLog scopes ───────────────────────────────────────────────────
it('scopeMissed returns only unsent log entries', function (): void {
$user = User::factory()->create();
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => true]);
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'daily_limit']);
NotificationLog::factory()->create(['user_id' => $user->id, 'sent' => false, 'missed_reason' => 'tier_restricted']);
expect(NotificationLog::missed()->where('user_id', $user->id)->count())->toBe(2);
});
it('scopeSentToday returns only sent entries for that channel today', function (): void {
$user = User::factory()->create();
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()]);
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'sent' => true, 'created_at' => now()->subDay()]);
NotificationLog::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'sent' => true, 'created_at' => now()]);
expect(NotificationLog::sentToday('sms')->where('user_id', $user->id)->count())->toBe(1);
});
// ─── UserNotificationPreference scopes ───────────────────────────────────────
it('scopeEnabled filters disabled preferences', function (): void {
$user = User::factory()->create();
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => FuelType::E10->value, 'enabled' => true]);
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => FuelType::E10->value, 'enabled' => false]);
expect(UserNotificationPreference::enabled()->where('user_id', $user->id)->count())->toBe(1);
});
it('scopeForChannel filters by channel', function (): void {
$user = User::factory()->create();
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'sms']);
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'channel' => 'email']);
expect(UserNotificationPreference::forChannel('sms')->where('user_id', $user->id)->count())->toBe(1);
});
it('scopeForFuelType filters by fuel type', function (): void {
$user = User::factory()->create();
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E10->value]);
UserNotificationPreference::factory()->create(['user_id' => $user->id, 'fuel_type' => FuelType::E5->value]);
expect(UserNotificationPreference::forFuelType(FuelType::E10->value)->where('user_id', $user->id)->count())->toBe(1);
});

View File

@@ -0,0 +1,78 @@
<?php
use App\Filament\Resources\Plans\Pages\EditPlan;
use App\Filament\Resources\Plans\Pages\ListPlans;
use App\Models\Plan;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
$this->actingAs(User::factory()->create(['is_admin' => true]));
});
// ─── ListPlans ────────────────────────────────────────────────────────────────
it('lists all four plans', function (): void {
Livewire::test(ListPlans::class)
->assertCanSeeTableRecords(Plan::all());
});
it('has no create button on the list page', function (): void {
Livewire::test(ListPlans::class)
->assertActionDoesNotExist('create');
});
// ─── EditPlan — no delete ─────────────────────────────────────────────────────
it('has no delete action on the edit page', function (): void {
$plan = Plan::where('name', 'basic')->first();
Livewire::test(EditPlan::class, ['record' => $plan->id])
->assertActionDoesNotExist(DeleteAction::class);
});
// ─── EditPlan — saves features correctly ─────────────────────────────────────
it('saves email frequency on edit', function (): void {
$plan = Plan::where('name', 'free')->first();
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.email.frequency' => 'daily',
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['email']['frequency'])->toBe('daily');
});
it('saves sms daily limit on edit', function (): void {
$plan = Plan::where('name', 'plus')->first();
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.sms.daily_limit' => 3,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['sms']['daily_limit'])->toBe(3);
});
it('saves null fuel type max for pro (unlimited)', function (): void {
$plan = Plan::where('name', 'pro')->first();
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.fuel_types.max' => null,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['fuel_types']['max'])->toBeNull();
});