Compare commits
2 Commits
d25883ead4
...
4220b1b86a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4220b1b86a | ||
|
|
3cd3467178 |
394
.claude/rules/tiers.md
Normal file
394
.claude/rules/tiers.md
Normal 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`
|
||||||
@@ -65,3 +65,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
FUELALERT_API_KEY=
|
FUELALERT_API_KEY=
|
||||||
|
|
||||||
|
FRED_API_KEY=
|
||||||
|
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ npm run dev # Vite asset watcher
|
|||||||
@.claude/rules/notifications.md
|
@.claude/rules/notifications.md
|
||||||
@.claude/rules/scoring.md
|
@.claude/rules/scoring.md
|
||||||
@.claude/rules/payments.md
|
@.claude/rules/payments.md
|
||||||
|
@.claude/rules/tiers.md
|
||||||
@.claude/rules/livewire.md
|
@.claude/rules/livewire.md
|
||||||
@.claude/rules/api-data.md
|
@.claude/rules/api-data.md
|
||||||
@.claude/rules/testing.md
|
@.claude/rules/testing.md
|
||||||
|
|||||||
11
app/Enums/PlanTier.php
Normal file
11
app/Enums/PlanTier.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PlanTier: string
|
||||||
|
{
|
||||||
|
case Free = 'free';
|
||||||
|
case Basic = 'basic';
|
||||||
|
case Plus = 'plus';
|
||||||
|
case Pro = 'pro';
|
||||||
|
}
|
||||||
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal file
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal file
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/PlanResource.php
Normal file
40
app/Filament/Resources/Plans/PlanResource.php
Normal 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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
91
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal file
91
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal 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'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal file
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Middleware/RequiresFeature.php
Normal file
30
app/Http/Middleware/RequiresFeature.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
app/Jobs/DispatchUserNotificationJob.php
Normal file
87
app/Jobs/DispatchUserNotificationJob.php
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Jobs/SendScheduledWhatsAppJob.php
Normal file
64
app/Jobs/SendScheduledWhatsAppJob.php
Normal 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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Models/NotificationLog.php
Normal file
56
app/Models/NotificationLog.php
Normal 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
71
app/Models/Plan.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,4 +58,14 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
{
|
{
|
||||||
return $this->hasMany(SavedStation::class);
|
return $this->hasMany(SavedStation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notificationPreferences(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserNotificationPreference::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notificationLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NotificationLog::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
49
app/Models/UserNotificationPreference.php
Normal file
49
app/Models/UserNotificationPreference.php
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/Services/PlanFeatures.php
Normal file
198
app/Services/PlanFeatures.php
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\RequiresFeature;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->statefulApi();
|
$middleware->statefulApi();
|
||||||
|
$middleware->alias([
|
||||||
|
'feature' => RequiresFeature::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
|
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"filament/filament": "^5.0",
|
"filament/filament": "^5.0",
|
||||||
|
"laravel/cashier": "^16.5",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
|
|||||||
328
composer.lock
generated
328
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "789a2e6b542a1e2f263dc8e9c973423b",
|
"content-hash": "9035b4713dec553cc69f487efa60cade",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -2124,6 +2124,95 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-29T12:05:03+00:00"
|
"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",
|
"name": "laravel/fortify",
|
||||||
"version": "v1.36.2",
|
"version": "v1.36.2",
|
||||||
@@ -3537,6 +3626,96 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-02T20:48:35+00:00"
|
"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",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -5508,6 +5687,65 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-01T09:30:04+00:00"
|
"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",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -6708,6 +6946,94 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"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",
|
"name": "symfony/polyfill-intl-idn",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ return [
|
|||||||
'api_key' => env('FRED_API_KEY'),
|
'api_key' => env('FRED_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'eia' => [
|
||||||
|
'api_key' => env('EIA_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
'anthropic' => [
|
'anthropic' => [
|
||||||
'api_key' => env('ANTHROPIC_API_KEY'),
|
'api_key' => env('ANTHROPIC_API_KEY'),
|
||||||
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
||||||
@@ -68,4 +72,12 @@ return [
|
|||||||
'api_key' => env('FUELALERT_API_KEY'),
|
'api_key' => env('FUELALERT_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'prices' => [
|
||||||
|
'basic' => env('STRIPE_PRICE_BASIC'),
|
||||||
|
'plus' => env('STRIPE_PRICE_PLUS'),
|
||||||
|
'pro' => env('STRIPE_PRICE_PRO'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
36
database/factories/NotificationLogFactory.php
Normal file
36
database/factories/NotificationLogFactory.php
Normal 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
97
database/factories/PlanFactory.php
Normal file
97
database/factories/PlanFactory.php
Normal 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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
database/factories/UserNotificationPreferenceFactory.php
Normal file
24
database/factories/UserNotificationPreferenceFactory.php
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
25
database/migrations/2026_04_14_105915_create_plans_table.php
Normal file
25
database/migrations/2026_04_14_105915_create_plans_table.php
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -13,7 +13,10 @@ class DatabaseSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call(AdminSeeder::class);
|
$this->call([
|
||||||
|
PlanSeeder::class,
|
||||||
|
AdminSeeder::class,
|
||||||
|
]);
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'name' => 'Test User',
|
||||||
|
|||||||
79
database/seeders/PlanSeeder.php
Normal file
79
database/seeders/PlanSeeder.php
Normal 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
227
docs/tiers.md
Normal 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 |
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Jobs\SendScheduledWhatsAppJob;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@@ -28,3 +29,7 @@ Schedule::command('oil:predict --fetch')
|
|||||||
->withoutOverlapping()
|
->withoutOverlapping()
|
||||||
->onOneServer()
|
->onOneServer()
|
||||||
->runInBackground();
|
->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();
|
||||||
|
|||||||
225
tests/Feature/Tiers/DispatchUserNotificationJobTest.php
Normal file
225
tests/Feature/Tiers/DispatchUserNotificationJobTest.php
Normal 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';
|
||||||
|
});
|
||||||
|
});
|
||||||
210
tests/Feature/Tiers/PlanFeaturesTest.php
Normal file
210
tests/Feature/Tiers/PlanFeaturesTest.php
Normal 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);
|
||||||
|
});
|
||||||
78
tests/Feature/Tiers/PlanResourceTest.php
Normal file
78
tests/Feature/Tiers/PlanResourceTest.php
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user