diff --git a/app/Enums/PriceReliability.php b/app/Enums/PriceReliability.php new file mode 100644 index 0000000..880ac83 --- /dev/null +++ b/app/Enums/PriceReliability.php @@ -0,0 +1,45 @@ +diffInHours(now()); + + return match (true) { + $hours <= 72 => self::Reliable, + $hours <= 168 => self::Stale, + default => self::Outdated, + }; + } + + public function weight(): int + { + return match ($this) { + self::Reliable => 0, + self::Stale => 1, + self::Outdated => 2, + }; + } + + public function label(): string + { + return match ($this) { + self::Reliable => 'Reliable', + self::Stale => 'Older price — verify before driving', + self::Outdated => 'Outdated — may be inaccurate', + }; + } +} diff --git a/app/Filament/Resources/UserResource/Widgets/MissedNotificationsOverview.php b/app/Filament/Resources/UserResource/Widgets/MissedNotificationsOverview.php new file mode 100644 index 0000000..b5952cd --- /dev/null +++ b/app/Filament/Resources/UserResource/Widgets/MissedNotificationsOverview.php @@ -0,0 +1,43 @@ +record === null) { + return []; + } + + $userId = $this->record->id; + + $missedTodayByChannel = fn (string $channel): int => NotificationLog::where('user_id', $userId) + ->where('channel', $channel) + ->where('sent', false) + ->whereDate('created_at', today()) + ->count(); + + $missedThisMonth = NotificationLog::where('user_id', $userId) + ->where('sent', false) + ->whereMonth('created_at', now()->month) + ->whereYear('created_at', now()->year) + ->count(); + + return [ + Stat::make('SMS missed today', $missedTodayByChannel('sms')) + ->color($missedTodayByChannel('sms') > 0 ? 'warning' : 'gray'), + Stat::make('WhatsApp missed today', $missedTodayByChannel('whatsapp')) + ->color($missedTodayByChannel('whatsapp') > 0 ? 'warning' : 'gray'), + Stat::make('Total missed this month', $missedThisMonth) + ->color($missedThisMonth > 0 ? 'warning' : 'gray'), + ]; + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..d799e6b --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,48 @@ +value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404); + abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404); + + $priceId = config("services.stripe.prices.{$tier}.{$cadence}"); + + abort_if(empty($priceId), 404, "No Stripe price configured for {$tier} {$cadence}"); + + return $request->user() + ->newSubscription('default', $priceId) + ->allowPromotionCodes() + ->checkout([ + 'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}', + 'cancel_url' => route('billing.cancel'), + ]); + } + + /** Redirect the user to the Stripe-hosted Customer Billing Portal. */ + public function portal(Request $request): Response|RedirectResponse + { + return $request->user()->redirectToBillingPortal(route('dashboard')); + } + + public function success(): RedirectResponse + { + return redirect()->route('dashboard')->with('status', 'subscription_started'); + } + + public function cancel(): RedirectResponse + { + return redirect()->route('dashboard')->with('status', 'subscription_cancelled'); + } +} diff --git a/app/Listeners/DowngradeUserOnSubscriptionDeleted.php b/app/Listeners/DowngradeUserOnSubscriptionDeleted.php new file mode 100644 index 0000000..eaee68c --- /dev/null +++ b/app/Listeners/DowngradeUserOnSubscriptionDeleted.php @@ -0,0 +1,37 @@ +payload['type'] ?? null) !== 'customer.subscription.deleted') { + return; + } + + $stripeCustomerId = $event->payload['data']['object']['customer'] ?? null; + + if (! $stripeCustomerId) { + return; + } + + $user = User::where('stripe_id', $stripeCustomerId)->first(); + + if (! $user) { + return; + } + + UserNotificationPreference::query() + ->where('user_id', $user->id) + ->whereIn('channel', ['whatsapp', 'sms']) + ->update(['enabled' => false]); + + Cache::tags(['plans'])->forget("plan_for_user_{$user->id}"); + } +} diff --git a/database/migrations/2026_04_14_182632_add_annual_stripe_price_id_to_plans_table.php b/database/migrations/2026_04_14_182632_add_annual_stripe_price_id_to_plans_table.php new file mode 100644 index 0000000..7b1f1fc --- /dev/null +++ b/database/migrations/2026_04_14_182632_add_annual_stripe_price_id_to_plans_table.php @@ -0,0 +1,31 @@ +renameColumn('stripe_price_id', 'stripe_price_id_monthly'); + }); + + Schema::table('plans', function (Blueprint $table) { + $table->string('stripe_price_id_annual')->nullable()->after('stripe_price_id_monthly') + ->comment('Cashier price ID for annual billing'); + }); + } + + public function down(): void + { + Schema::table('plans', function (Blueprint $table) { + $table->dropColumn('stripe_price_id_annual'); + }); + + Schema::table('plans', function (Blueprint $table) { + $table->renameColumn('stripe_price_id_monthly', 'stripe_price_id'); + }); + } +}; diff --git a/database/migrations/2026_04_14_213226_normalize_preferred_fuel_type_aliases.php b/database/migrations/2026_04_14_213226_normalize_preferred_fuel_type_aliases.php new file mode 100644 index 0000000..1105d19 --- /dev/null +++ b/database/migrations/2026_04_14_213226_normalize_preferred_fuel_type_aliases.php @@ -0,0 +1,38 @@ + 'e10', + 'unleaded' => 'e10', + 'premium_unleaded' => 'e5', + 'diesel' => 'b7_standard', + 'premium_diesel' => 'b7_premium', + ]; + + foreach ($map as $from => $to) { + DB::table('users')->where('preferred_fuel_type', $from)->update(['preferred_fuel_type' => $to]); + } + + Schema::table('users', function (Blueprint $table) { + $table->string('preferred_fuel_type', 20)->default('e10')->change(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->string('preferred_fuel_type', 20)->default('petrol')->change(); + }); + + DB::table('users')->where('preferred_fuel_type', 'e10')->update(['preferred_fuel_type' => 'petrol']); + DB::table('users')->where('preferred_fuel_type', 'b7_standard')->update(['preferred_fuel_type' => 'diesel']); + } +}; diff --git a/docs/superpowers/specs/2026-04-15-tier-features-design.md b/docs/superpowers/specs/2026-04-15-tier-features-design.md new file mode 100644 index 0000000..1c38658 --- /dev/null +++ b/docs/superpowers/specs/2026-04-15-tier-features-design.md @@ -0,0 +1,144 @@ +# FuelAlert — Tier Feature Design (Free / Basic / Plus / Pro) + +## Context + +The existing tier rules (`.claude/rules/tiers.md`) define notification channels +per plan but not the *information* features that make each tier feel worth +paying for. This spec closes that gap by cataloguing always-on information, +personalisation, and analytics features — the value a user sees whether or not +a notification fires — and mapping them to the four tiers. Triggered channel +rules (email/push/WhatsApp/SMS) remain unchanged and are not redefined here. + +Goals: +- Give each upgrade a concrete "what do I get for £X more" answer. +- Keep free tier useful enough to retain, but with clear ceiling. +- Keep Plus the commercial sweet spot (LLM prediction + fuel logs + MPG). +- Keep Pro genuinely premium (unlimited, multi-location, route planning, family sharing). + +Teaser strategy: **hybrid** — headline features (prediction, extended history, +detailed confidence) are visible but blurred with an upgrade CTA on Free; minor +Pro-only features (route planner, family sharing) are hidden from lower tiers. + +--- + +## Feature Matrix + +### 1. Fill-up recommendation (from `AlertScoringService`) + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Recommendation (`fill_up` / `wait` / `no_signal`) | ✓ | ✓ | ✓ | ✓ | +| Confidence % + reason string | — | ✓ | ✓ | ✓ | +| Per-signal breakdown (which of 5 signals fired, weights) | — | — | — | ✓ | +| Accuracy self-tracking ("our last wait call saved you Xp/L") | — | — | ✓ | ✓ | + +### 2. Oil price prediction (from `price_predictions`) + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Direction only (EWMA, no reasoning) | — | ✓ | ✓ | ✓ | +| Full LLM prediction (direction + confidence + reasoning) | — | — | ✓ | ✓ | +| Historical prediction accuracy view | — | — | — | ✓ | + +Rationale: Basic sees *that* prices are expected to rise/fall; Plus sees *why*. + +### 3. Price history & trends + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Local price today | ✓ | ✓ | ✓ | ✓ | +| Price history chart | 7 days | 14 days | 90 days | 365 days | +| Brand comparison ("Tesco avg vs Shell avg near you") | — | — | ✓ | ✓ | +| Local price leaderboard (cheapest N nearby) | top 3 / 5km | top 5 / 5km | top 10 / 15km | top 20 / 50km | + +### 4. Saved data & personalisation + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Saved home postcode | ✓ | ✓ | ✓ | ✓ | +| Tracked fuel types | 1 | 1 | 1 | unlimited | +| Saved stations (favourites) | 1 | 3 | 10 | unlimited | +| Multiple locations (home/work/commute) | — | — | 2 | 5 | +| Custom price thresholds per fuel/station | — | 1 | 3 | unlimited | + +(Fuel-type cap for free/basic/plus matches existing `tiers.md` rule.) + +### 5. Fuel logs & personal analytics (new) + +Simple log: date, litres, price per litre, optional odometer. + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Log fill-ups (basic entry) | — | last 10 | last 100 | unlimited | +| MPG / cost-per-mile (needs odometer) | — | — | ✓ | ✓ | +| Monthly spend report | — | — | ✓ | ✓ | +| "You saved £X by following our advice" attribution | — | — | — | ✓ | +| CSV export of fuel log | — | — | — | ✓ | + +### 6. Tools (new, mostly Pro) + +| Feature | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| Route planner — cheapest station along A→B | — | — | — | ✓ | +| Commute calculator — is the detour worth it? | — | — | — | ✓ | +| Family / household sharing (2 extra members on one account) | — | — | — | ✓ | + +--- + +## Plan `features` JSON shape (additions) + +Extend the existing `features` JSON in the `plans` table (see `tiers.md` for +existing shape). New keys to add: + +```json +{ + "history_days": 7, + "prediction": { "level": "none" }, + "recommendation": { "confidence_visible": false, "signal_breakdown": false, "accuracy_tracking": false }, + "leaderboard": { "count": 3, "radius_km": 5 }, + "saved_stations": { "max": 1 }, + "locations": { "max": 1 }, + "thresholds": { "max": 0 }, + "fuel_log": { "enabled": false, "max_entries": 0, "mpg": false, "monthly_report": false, "savings_attribution": false, "csv_export": false }, + "brand_comparison": false, + "route_planner": false, + "commute_calculator": false, + "family_sharing": { "enabled": false, "max_members": 0 } +} +``` + +`prediction.level` values: `none` | `direction` (EWMA only) | `full` (LLM + +reasoning) | `full_with_accuracy` (Pro). + +### Seeded values per tier + +| Key | Free | Basic | Plus | Pro | +|---|---|---|---|---| +| `history_days` | 7 | 14 | 90 | 365 | +| `prediction.level` | `none` | `direction` | `full` | `full_with_accuracy` | +| `recommendation.confidence_visible` | false | true | true | true | +| `recommendation.signal_breakdown` | false | false | false | true | +| `recommendation.accuracy_tracking` | false | false | true | true | +| `leaderboard.count` / `radius_km` | 3 / 5 | 5 / 5 | 10 / 15 | 20 / 50 | +| `saved_stations.max` | 1 | 3 | 10 | null (unlimited) | +| `locations.max` | 1 | 1 | 2 | 5 | +| `thresholds.max` | 0 | 1 | 3 | null | +| `fuel_log.enabled` | false | true | true | true | +| `fuel_log.max_entries` | 0 | 10 | 100 | null | +| `fuel_log.mpg` | false | false | true | true | +| `fuel_log.monthly_report` | false | false | true | true | +| `fuel_log.savings_attribution` | false | false | false | true | +| `fuel_log.csv_export` | false | false | false | true | +| `brand_comparison` | false | false | true | true | +| `route_planner` | false | false | false | true | +| `commute_calculator` | false | false | false | true | +| `family_sharing.enabled` / `max_members` | false / 0 | false / 0 | false / 0 | true / 2 | + +--- + +## Out of scope (explicit) + +- Implementation of route planner, commute calculator, brand comparison, family sharing — this spec gates them but their actual logic/UI is separate projects. +- Fuel log UI/import — separate project; this spec only defines the model and caps. +- Ads on free tier — dropped per user decision. +- Public API, shareable widgets, early access flags — dropped per user decision. diff --git a/resources/js/constants/fuelTypes.js b/resources/js/constants/fuelTypes.js new file mode 100644 index 0000000..0fb0464 --- /dev/null +++ b/resources/js/constants/fuelTypes.js @@ -0,0 +1,9 @@ +// Canonical fuel type labels come from PHP enum App\Enums\FuelType. +// They are injected into window.FUEL_TYPES by resources/views/app.blade.php +// so both backend (Filament forms, notifications) and frontend share one source. + +export const FUEL_TYPES = window.FUEL_TYPES ?? [] + +export const FUEL_TYPE_LABELS = Object.fromEntries( + FUEL_TYPES.map((t) => [t.value, t.label]), +) diff --git a/tests/Feature/Console/PollFuelPricesTest.php b/tests/Feature/Console/PollFuelPricesTest.php new file mode 100644 index 0000000..08a2ef0 --- /dev/null +++ b/tests/Feature/Console/PollFuelPricesTest.php @@ -0,0 +1,120 @@ + Http::sequence() + ->push([[ + 'node_id' => 'sta1', + 'trading_name' => 'Village Garage', + 'brand_name' => 'Village Garage', + 'is_same_trading_and_brand_name' => true, + 'is_motorway_service_station' => false, + 'is_supermarket_service_station' => false, + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'location' => [ + 'address_line_1' => '1 High Street', + 'address_line_2' => null, + 'city' => 'London', + 'county' => null, + 'country' => 'England', + 'postcode' => 'SW1A 1AA', + 'latitude' => 51.5, + 'longitude' => -0.1, + ], + 'amenities' => [], + 'opening_times' => null, + 'fuel_types' => ['E10'], + ]]) + ->push([]), + '*/pfs/fuel-prices*' => Http::sequence() + ->push([[ + 'node_id' => 'sta1', + 'fuel_prices' => [[ + 'fuel_type' => 'E10', + 'price' => 142.9, + 'price_last_updated' => '2026-04-04T10:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-04T10:00:00.000Z', + ]], + ]]) + ->push([]), + ]); + + $this->artisan('fuel:poll')->assertSuccessful(); + + expect(Station::find('sta1'))->not->toBeNull(); +}); + +it('auto-refreshes station metadata when the last refresh was yesterday', function (): void { + Station::factory()->create([ + 'node_id' => 'sta1', + 'last_seen_at' => now()->subDay()->endOfDay(), + ]); + + Http::fake([ + '*/pfs?*' => Http::sequence() + ->push([[ + 'node_id' => 'sta1', + 'trading_name' => 'Refreshed Name', + 'brand_name' => 'Refreshed Name', + 'is_same_trading_and_brand_name' => true, + 'is_motorway_service_station' => false, + 'is_supermarket_service_station' => false, + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'location' => [ + 'address_line_1' => '1 High Street', + 'address_line_2' => null, + 'city' => 'London', + 'county' => null, + 'country' => 'England', + 'postcode' => 'SW1A 1AA', + 'latitude' => 51.5, + 'longitude' => -0.1, + ], + 'amenities' => [], + 'opening_times' => null, + 'fuel_types' => ['E10'], + ]]) + ->push([]), + '*/pfs/fuel-prices*' => Http::sequence() + ->push([]) + ->push([]), + ]); + + $this->artisan('fuel:poll')->assertSuccessful(); + + expect(Station::find('sta1')->trading_name)->toBe('Refreshed Name'); +}); + +it('skips the station metadata refresh when already refreshed today', function (): void { + Station::factory()->create([ + 'node_id' => 'sta1', + 'last_seen_at' => now(), + ]); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([]) + ->push([]), + ]); + + $this->artisan('fuel:poll')->assertSuccessful(); + + Http::assertNotSent(fn ($request) => str_contains($request->url(), '/pfs?')); +}); diff --git a/tests/Feature/Payments/AssignTierFromEditPageTest.php b/tests/Feature/Payments/AssignTierFromEditPageTest.php new file mode 100644 index 0000000..422a88d --- /dev/null +++ b/tests/Feature/Payments/AssignTierFromEditPageTest.php @@ -0,0 +1,66 @@ +artisan('db:seed', ['--class' => 'PlanSeeder']); + $admin = User::factory()->create(['is_admin' => true]); + $this->actingAs($admin); +}); + +it('creates an admin-granted subscription when a paid tier is chosen', function (): void { + $user = User::factory()->create(); + + Livewire::test(EditUser::class, ['record' => $user->id]) + ->fillForm(['tier' => 'basic', 'cadence' => 'monthly']) + ->call('save') + ->assertHasNoFormErrors(); + + $subscription = $user->subscriptions()->first(); + expect($user->subscriptions()->count())->toBe(1) + ->and($subscription->stripe_id)->toStartWith('admin_') + ->and($subscription->stripe_status)->toBe('active') + ->and(Plan::resolveForUser($user->fresh())->name)->toBe('basic'); +}); + +it('removes any admin-granted subscription when tier is set to free', function (): void { + $user = User::factory()->create(); + + Livewire::test(EditUser::class, ['record' => $user->id]) + ->fillForm(['tier' => 'basic', 'cadence' => 'monthly']) + ->call('save'); + expect($user->subscriptions()->count())->toBe(1); + + Livewire::test(EditUser::class, ['record' => $user->fresh()->id]) + ->fillForm(['tier' => 'free']) + ->call('save'); + + expect($user->subscriptions()->count())->toBe(0) + ->and(Plan::resolveForUser($user->fresh())->name)->toBe('free'); +}); + +it('refuses to change tier when the user has a real Stripe subscription', function (): void { + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_real_stripe_id', + 'stripe_status' => 'active', + 'stripe_price' => 'price_plus_monthly', + 'quantity' => 1, + ]); + + Livewire::test(EditUser::class, ['record' => $user->id]) + ->fillForm(['tier' => 'pro', 'cadence' => 'monthly']) + ->call('save'); + + $adminCount = $user->subscriptions()->where('stripe_id', 'like', 'admin_%')->count(); + expect($adminCount)->toBe(0) + ->and($user->subscriptions()->count())->toBe(1); +}); diff --git a/tests/Feature/Payments/BillingControllerTest.php b/tests/Feature/Payments/BillingControllerTest.php new file mode 100644 index 0000000..d67fd64 --- /dev/null +++ b/tests/Feature/Payments/BillingControllerTest.php @@ -0,0 +1,57 @@ + 'price_test_basic_monthly', + 'services.stripe.prices.basic.annual' => 'price_test_basic_annual', + 'services.stripe.prices.plus.monthly' => 'price_test_plus_monthly', + 'services.stripe.prices.plus.annual' => 'price_test_plus_annual', + 'services.stripe.prices.pro.monthly' => null, + 'services.stripe.prices.pro.annual' => null, + ]); +}); + +it('requires authentication for the checkout route', function (): void { + $this->get('/billing/checkout/basic/monthly')->assertRedirect('/login'); +}); + +it('requires authentication for the portal route', function (): void { + $this->get('/billing/portal')->assertRedirect('/login'); +}); + +it('rejects an unknown tier', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/billing/checkout/titanium/monthly') + ->assertNotFound(); +}); + +it('rejects an unknown cadence', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/billing/checkout/basic/weekly') + ->assertNotFound(); +}); + +it('returns 404 when no price is configured for the tier + cadence', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/billing/checkout/pro/monthly') + ->assertNotFound(); +}); + +it('redirects the success route to the dashboard', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/billing/success') + ->assertRedirect(route('dashboard')) + ->assertSessionHas('status', 'subscription_started'); +}); + +it('redirects the cancel route to the dashboard', function (): void { + $this->actingAs(User::factory()->create()) + ->get('/billing/cancel') + ->assertRedirect(route('dashboard')) + ->assertSessionHas('status', 'subscription_cancelled'); +}); diff --git a/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php b/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php new file mode 100644 index 0000000..0aedc7a --- /dev/null +++ b/tests/Feature/Payments/DowngradeUserOnSubscriptionDeletedTest.php @@ -0,0 +1,42 @@ +create(['stripe_id' => 'cus_test_123']); + + UserNotificationPreference::query()->insert([ + ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'sms', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ['user_id' => $user->id, 'channel' => 'email', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ]); + + (new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['customer' => 'cus_test_123']], + ])); + + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'sms')->value('enabled'))->toBeFalse(); + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'email')->value('enabled'))->toBeTrue(); +}); + +it('ignores non-deletion webhook events', function (): void { + $user = User::factory()->create(['stripe_id' => 'cus_test_456']); + UserNotificationPreference::query()->insert([ + ['user_id' => $user->id, 'channel' => 'whatsapp', 'fuel_type' => 'E10', 'enabled' => true, 'created_at' => now(), 'updated_at' => now()], + ]); + + (new DowngradeUserOnSubscriptionDeleted)->handle(new WebhookReceived([ + 'type' => 'customer.subscription.updated', + 'data' => ['object' => ['customer' => 'cus_test_456']], + ])); + + expect(UserNotificationPreference::where('user_id', $user->id)->where('channel', 'whatsapp')->value('enabled'))->toBeTrue(); +});