From 4220b1b86ae99d36526493f874703b23dd827504 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Tue, 14 Apr 2026 16:20:51 +0100 Subject: [PATCH] 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 --- .claude/rules/tiers.md | 394 ++++++++++++++++++ CLAUDE.md | 1 + app/Enums/PlanTier.php | 11 + .../Resources/Plans/Pages/EditPlan.php | 24 ++ .../Resources/Plans/Pages/ListPlans.php | 16 + app/Filament/Resources/Plans/PlanResource.php | 40 ++ .../Resources/Plans/Schemas/PlanForm.php | 91 ++++ .../Resources/Plans/Tables/PlansTable.php | 40 ++ app/Http/Middleware/RequiresFeature.php | 30 ++ app/Jobs/DispatchUserNotificationJob.php | 87 ++++ app/Jobs/SendScheduledWhatsAppJob.php | 64 +++ app/Models/NotificationLog.php | 56 +++ app/Models/Plan.php | 71 ++++ app/Models/User.php | 10 + app/Models/UserNotificationPreference.php | 49 +++ app/Services/PlanFeatures.php | 198 +++++++++ bootstrap/app.php | 4 + composer.json | 1 + composer.lock | 328 ++++++++++++++- database/factories/NotificationLogFactory.php | 36 ++ database/factories/PlanFactory.php | 97 +++++ .../UserNotificationPreferenceFactory.php | 24 ++ ...4_105915_create_notification_log_table.php | 31 ++ .../2026_04_14_105915_create_plans_table.php | 25 ++ ...te_user_notification_preferences_table.php | 27 ++ ...6_04_14_110300_create_customer_columns.php | 40 ++ ...4_14_110301_create_subscriptions_table.php | 37 ++ ...110302_create_subscription_items_table.php | 34 ++ ...d_meter_id_to_subscription_items_table.php | 28 ++ ...event_name_to_subscription_items_table.php | 28 ++ database/seeders/DatabaseSeeder.php | 5 +- database/seeders/PlanSeeder.php | 79 ++++ docs/tiers.md | 227 ++++++++++ routes/console.php | 5 + .../Tiers/DispatchUserNotificationJobTest.php | 225 ++++++++++ tests/Feature/Tiers/PlanFeaturesTest.php | 210 ++++++++++ tests/Feature/Tiers/PlanResourceTest.php | 78 ++++ 37 files changed, 2749 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/tiers.md create mode 100644 app/Enums/PlanTier.php create mode 100644 app/Filament/Resources/Plans/Pages/EditPlan.php create mode 100644 app/Filament/Resources/Plans/Pages/ListPlans.php create mode 100644 app/Filament/Resources/Plans/PlanResource.php create mode 100644 app/Filament/Resources/Plans/Schemas/PlanForm.php create mode 100644 app/Filament/Resources/Plans/Tables/PlansTable.php create mode 100644 app/Http/Middleware/RequiresFeature.php create mode 100644 app/Jobs/DispatchUserNotificationJob.php create mode 100644 app/Jobs/SendScheduledWhatsAppJob.php create mode 100644 app/Models/NotificationLog.php create mode 100644 app/Models/Plan.php create mode 100644 app/Models/UserNotificationPreference.php create mode 100644 app/Services/PlanFeatures.php create mode 100644 database/factories/NotificationLogFactory.php create mode 100644 database/factories/PlanFactory.php create mode 100644 database/factories/UserNotificationPreferenceFactory.php create mode 100644 database/migrations/2026_04_14_105915_create_notification_log_table.php create mode 100644 database/migrations/2026_04_14_105915_create_plans_table.php create mode 100644 database/migrations/2026_04_14_105915_create_user_notification_preferences_table.php create mode 100644 database/migrations/2026_04_14_110300_create_customer_columns.php create mode 100644 database/migrations/2026_04_14_110301_create_subscriptions_table.php create mode 100644 database/migrations/2026_04_14_110302_create_subscription_items_table.php create mode 100644 database/migrations/2026_04_14_110303_add_meter_id_to_subscription_items_table.php create mode 100644 database/migrations/2026_04_14_110304_add_meter_event_name_to_subscription_items_table.php create mode 100644 database/seeders/PlanSeeder.php create mode 100644 docs/tiers.md create mode 100644 tests/Feature/Tiers/DispatchUserNotificationJobTest.php create mode 100644 tests/Feature/Tiers/PlanFeaturesTest.php create mode 100644 tests/Feature/Tiers/PlanResourceTest.php diff --git a/.claude/rules/tiers.md b/.claude/rules/tiers.md new file mode 100644 index 0000000..ebd7f3f --- /dev/null +++ b/.claude/rules/tiers.md @@ -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` diff --git a/CLAUDE.md b/CLAUDE.md index fac0a15..5ce6614 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/app/Enums/PlanTier.php b/app/Enums/PlanTier.php new file mode 100644 index 0000000..2d29c58 --- /dev/null +++ b/app/Enums/PlanTier.php @@ -0,0 +1,11 @@ +flush(); + } + } +} diff --git a/app/Filament/Resources/Plans/Pages/ListPlans.php b/app/Filament/Resources/Plans/Pages/ListPlans.php new file mode 100644 index 0000000..cfd26c9 --- /dev/null +++ b/app/Filament/Resources/Plans/Pages/ListPlans.php @@ -0,0 +1,16 @@ + ListPlans::route('/'), + 'edit' => EditPlan::route('/{record}/edit'), + ]; + } +} diff --git a/app/Filament/Resources/Plans/Schemas/PlanForm.php b/app/Filament/Resources/Plans/Schemas/PlanForm.php new file mode 100644 index 0000000..0664013 --- /dev/null +++ b/app/Filament/Resources/Plans/Schemas/PlanForm.php @@ -0,0 +1,91 @@ +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'), + ]), + ]); + } +} diff --git a/app/Filament/Resources/Plans/Tables/PlansTable.php b/app/Filament/Resources/Plans/Tables/PlansTable.php new file mode 100644 index 0000000..a7e751e --- /dev/null +++ b/app/Filament/Resources/Plans/Tables/PlansTable.php @@ -0,0 +1,40 @@ +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(), + ]); + } +} diff --git a/app/Http/Middleware/RequiresFeature.php b/app/Http/Middleware/RequiresFeature.php new file mode 100644 index 0000000..3a5a4bf --- /dev/null +++ b/app/Http/Middleware/RequiresFeature.php @@ -0,0 +1,30 @@ +user(); + + if (! $user || ! PlanFeatures::for($user)->can($feature)) { + return response()->json([ + 'error' => 'upgrade_required', + 'feature' => $feature, + ], Response::HTTP_FORBIDDEN); + } + + return $next($request); + } +} diff --git a/app/Jobs/DispatchUserNotificationJob.php b/app/Jobs/DispatchUserNotificationJob.php new file mode 100644 index 0000000..b9ef516 --- /dev/null +++ b/app/Jobs/DispatchUserNotificationJob.php @@ -0,0 +1,87 @@ +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(); + } +} diff --git a/app/Jobs/SendScheduledWhatsAppJob.php b/app/Jobs/SendScheduledWhatsAppJob.php new file mode 100644 index 0000000..84fc6eb --- /dev/null +++ b/app/Jobs/SendScheduledWhatsAppJob.php @@ -0,0 +1,64 @@ +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'); + }); + } +} diff --git a/app/Models/NotificationLog.php b/app/Models/NotificationLog.php new file mode 100644 index 0000000..5954a8a --- /dev/null +++ b/app/Models/NotificationLog.php @@ -0,0 +1,56 @@ + */ + 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', + ]; + } +} diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..e9091c7 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,71 @@ + */ + 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', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index d10e76a..529800c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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); + } } diff --git a/app/Models/UserNotificationPreference.php b/app/Models/UserNotificationPreference.php new file mode 100644 index 0000000..19cc990 --- /dev/null +++ b/app/Models/UserNotificationPreference.php @@ -0,0 +1,49 @@ + */ + 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', + ]; + } +} diff --git a/app/Services/PlanFeatures.php b/app/Services/PlanFeatures.php new file mode 100644 index 0000000..7b5f7e4 --- /dev/null +++ b/app/Services/PlanFeatures.php @@ -0,0 +1,198 @@ +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'; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c79ed7f..80223ae 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ 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/*')); diff --git a/composer.json b/composer.json index ca1982a..058b4b2 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index eed7129..643dcba 100644 --- a/composer.lock +++ b/composer.lock @@ -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", diff --git a/database/factories/NotificationLogFactory.php b/database/factories/NotificationLogFactory.php new file mode 100644 index 0000000..51419ea --- /dev/null +++ b/database/factories/NotificationLogFactory.php @@ -0,0 +1,36 @@ + + */ +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, + ]); + } +} diff --git a/database/factories/PlanFactory.php b/database/factories/PlanFactory.php new file mode 100644 index 0000000..91783cc --- /dev/null +++ b/database/factories/PlanFactory.php @@ -0,0 +1,97 @@ + + */ +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, + ], + ]); + } +} diff --git a/database/factories/UserNotificationPreferenceFactory.php b/database/factories/UserNotificationPreferenceFactory.php new file mode 100644 index 0000000..32f9b58 --- /dev/null +++ b/database/factories/UserNotificationPreferenceFactory.php @@ -0,0 +1,24 @@ + + */ +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, + ]; + } +} diff --git a/database/migrations/2026_04_14_105915_create_notification_log_table.php b/database/migrations/2026_04_14_105915_create_notification_log_table.php new file mode 100644 index 0000000..13d38ed --- /dev/null +++ b/database/migrations/2026_04_14_105915_create_notification_log_table.php @@ -0,0 +1,31 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_14_105915_create_plans_table.php b/database/migrations/2026_04_14_105915_create_plans_table.php new file mode 100644 index 0000000..a502bc3 --- /dev/null +++ b/database/migrations/2026_04_14_105915_create_plans_table.php @@ -0,0 +1,25 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_14_105915_create_user_notification_preferences_table.php b/database/migrations/2026_04_14_105915_create_user_notification_preferences_table.php new file mode 100644 index 0000000..ae2ff9c --- /dev/null +++ b/database/migrations/2026_04_14_105915_create_user_notification_preferences_table.php @@ -0,0 +1,27 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_14_110300_create_customer_columns.php b/database/migrations/2026_04_14_110300_create_customer_columns.php new file mode 100644 index 0000000..974b381 --- /dev/null +++ b/database/migrations/2026_04_14_110300_create_customer_columns.php @@ -0,0 +1,40 @@ +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', + ]); + }); + } +}; diff --git a/database/migrations/2026_04_14_110301_create_subscriptions_table.php b/database/migrations/2026_04_14_110301_create_subscriptions_table.php new file mode 100644 index 0000000..ccbcc6d --- /dev/null +++ b/database/migrations/2026_04_14_110301_create_subscriptions_table.php @@ -0,0 +1,37 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_14_110302_create_subscription_items_table.php b/database/migrations/2026_04_14_110302_create_subscription_items_table.php new file mode 100644 index 0000000..420e23f --- /dev/null +++ b/database/migrations/2026_04_14_110302_create_subscription_items_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2026_04_14_110303_add_meter_id_to_subscription_items_table.php b/database/migrations/2026_04_14_110303_add_meter_id_to_subscription_items_table.php new file mode 100644 index 0000000..033bb82 --- /dev/null +++ b/database/migrations/2026_04_14_110303_add_meter_id_to_subscription_items_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_04_14_110304_add_meter_event_name_to_subscription_items_table.php b/database/migrations/2026_04_14_110304_add_meter_event_name_to_subscription_items_table.php new file mode 100644 index 0000000..b157b3a --- /dev/null +++ b/database/migrations/2026_04_14_110304_add_meter_event_name_to_subscription_items_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index e34f431..b61c295 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -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', diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php new file mode 100644 index 0000000..6c21fcf --- /dev/null +++ b/database/seeders/PlanSeeder.php @@ -0,0 +1,79 @@ +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, + ] + ); + } + } +} diff --git a/docs/tiers.md b/docs/tiers.md new file mode 100644 index 0000000..81ccb2f --- /dev/null +++ b/docs/tiers.md @@ -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 | diff --git a/routes/console.php b/routes/console.php index ad2640f..494652c 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,5 +1,6 @@ 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(); diff --git a/tests/Feature/Tiers/DispatchUserNotificationJobTest.php b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php new file mode 100644 index 0000000..ddc3b45 --- /dev/null +++ b/tests/Feature/Tiers/DispatchUserNotificationJobTest.php @@ -0,0 +1,225 @@ +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'; + }); +}); diff --git a/tests/Feature/Tiers/PlanFeaturesTest.php b/tests/Feature/Tiers/PlanFeaturesTest.php new file mode 100644 index 0000000..d2f5908 --- /dev/null +++ b/tests/Feature/Tiers/PlanFeaturesTest.php @@ -0,0 +1,210 @@ +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); +}); diff --git a/tests/Feature/Tiers/PlanResourceTest.php b/tests/Feature/Tiers/PlanResourceTest.php new file mode 100644 index 0000000..8694fbc --- /dev/null +++ b/tests/Feature/Tiers/PlanResourceTest.php @@ -0,0 +1,78 @@ +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(); +});