diff --git a/.claude/rules/api-data.md b/.claude/rules/api-data.md index b66c58d..ed7f44d 100644 --- a/.claude/rules/api-data.md +++ b/.claude/rules/api-data.md @@ -5,7 +5,7 @@ - Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/` - Returns: all UK station prices + station metadata (~14,500 stations) - Update frequency: stations report within 30 minutes of price change -- Our polling interval: every 15 minutes via scheduler (incremental), full refresh once daily +- Our polling interval: every 30 minutes via scheduler (incremental using `effective-start-timestamp`), station metadata auto-refreshed once per day on the first poll after midnight ### Authentication @@ -35,24 +35,34 @@ Content-Type: application/json - Include token in every API request: `Authorization: Bearer {token}` #### Endpoints -- `GET /api/v1/pfs/fuel-prices?batch-number` — all/incremental station prices -- `GET /api/v1/pfs?batch-number` — all/incremental station metadata +- `GET /api/v1/pfs/fuel-prices?batch-number={n}` — all station prices (500 stations per batch) +- `GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental, only prices changed since timestamp +- `GET /api/v1/pfs?batch-number={n}` — all station metadata (500 per batch) +- `GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental station metadata **Fuel prices response fields** (array of stations): -- `node_id` — station identifier -- `trading_name` — station name +- `node_id`, `public_phone_number`, `trading_name` — station identifiers - `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}` -- Fuel types: `E5`, `E10`, `B7_STANDARD`, `B7_PREMIUM`, `B10`, `HVO` +- Fuel types (API casing): `E5`, `E10`, `B7_Standard`, `B7_Premium`, `B10`, `HVO` — lowercased on ingest via `FuelType::fromApiValue()` - Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence **Station metadata response fields** (array of stations): -- `node_id`, `trading_name`, `brand_name` +- `node_id`, `trading_name`, `brand_name`, `is_same_trading_and_brand_name`, `public_phone_number` - `is_supermarket_service_station`, `is_motorway_service_station` -- `temporary_closure`, `permanent_closure` -- `location` — `{address_line_1, city, postcode, latitude, longitude}` -- `amenities` — string array (e.g. `car_wash`, `adblue_pumps`) -- `fuel_types` — string array of available fuel types -- `opening_times` — per-day open/close times (not used in scoring) +- `temporary_closure`, `permanent_closure`, `permanent_closure_date` +- `location` — `{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}` +- `amenities` — **OBJECT** with boolean flags: `{adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}`. Normalised at ingest to a flat array of enabled keys. +- `fuel_types` — **OBJECT** with boolean flags: `{E10, E5, B7_Standard, B7_Premium, B10, HVO}`. Normalised at ingest to a flat array of enabled keys. +- `opening_times` — `usual_days.{monday..sunday}.{open, close, is_24_hours}` + `bank_holidays.type.{open_time, close_time, is_24_hours}`. Stored as raw JSON, not used in scoring. + +### Required-field validation +Stations missing any of `node_id`, `trading_name`, `location.postcode`, `location.latitude`, `location.longitude` are dropped at ingest with a warning. Price rows missing any of `fuel_type`, `price`, `price_last_updated`, `price_change_effective_timestamp` are skipped silently. + +### Incremental polling (FuelPriceService::pollPrices) +On each successful poll the wall-clock start time is cached under `fuel_finder_last_price_poll_at` (forever). The next poll sends this as `effective-start-timestamp`. Cold start (cache miss) performs a full fetch. + +### FK safety +Price batches are filtered against the `stations` table before insert — any station not yet in `stations` is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up. ### FuelPriceService responsibilities 1. Fetch OAuth token (cache it) diff --git a/.env.example b/.env.example index c731513..49a1c80 100644 --- a/.env.example +++ b/.env.example @@ -69,6 +69,11 @@ FUELALERT_API_KEY= FRED_API_KEY= EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata +STRIPE_KEY= +STRIPE_SECRET= +STRIPE_WEBHOOK_SECRET= +CASHIER_CURRENCY=gbp + STRIPE_PRICE_BASIC_MONTHLY= STRIPE_PRICE_BASIC_ANNUAL= STRIPE_PRICE_PLUS_MONTHLY= diff --git a/app/Console/Commands/PollFuelPrices.php b/app/Console/Commands/PollFuelPrices.php index cafcb87..d75f61c 100644 --- a/app/Console/Commands/PollFuelPrices.php +++ b/app/Console/Commands/PollFuelPrices.php @@ -3,22 +3,26 @@ namespace App\Console\Commands; use App\Events\PricesUpdatedEvent; +use App\Models\Station; use App\Services\FuelPriceService; use Illuminate\Console\Command; +use Illuminate\Support\Carbon; use Throwable; class PollFuelPrices extends Command { - protected $signature = 'fuel:poll {--full : Also refresh station metadata}'; + protected $signature = 'fuel:poll {--full : Force refresh station metadata before polling}'; protected $description = 'Poll the Fuel Finder API for latest prices'; public function handle(FuelPriceService $service): int { $fullRefresh = (bool) $this->option('full'); + $lastRefresh = Station::max('last_seen_at'); + $stationsStale = $lastRefresh === null || Carbon::parse($lastRefresh)->isBefore(today()); try { - if ($fullRefresh) { + if ($fullRefresh || $stationsStale) { $this->info('Refreshing station metadata...'); $service->refreshStations(); } diff --git a/app/Enums/FuelType.php b/app/Enums/FuelType.php index 49bba56..a980ebb 100644 --- a/app/Enums/FuelType.php +++ b/app/Enums/FuelType.php @@ -11,21 +11,20 @@ enum FuelType: string case B10 = 'b10'; case Hvo = 'hvo'; + public function label(): string + { + return match ($this) { + self::E10 => 'Petrol (E10)', + self::E5 => 'Premium (E5)', + self::B7Standard => 'Diesel (B7)', + self::B7Premium => 'Prem Diesel', + self::B10 => 'Diesel (B10)', + self::Hvo => 'HVO', + }; + } + public static function fromApiValue(string $value): self { return self::from(strtolower($value)); } - - public static function fromAlias(string $alias): self - { - return match (strtolower($alias)) { - 'diesel', 'b7_standard' => self::B7Standard, - 'premium_diesel', 'b7_premium' => self::B7Premium, - 'petrol', 'unleaded', 'e10' => self::E10, - 'premium_unleaded', 'e5' => self::E5, - 'b10' => self::B10, - 'hvo' => self::Hvo, - default => throw new \ValueError("Unknown fuel type alias: {$alias}"), - }; - } } diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index ce53cd2..604254b 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -2,20 +2,28 @@ namespace App\Filament\Resources; +use App\Enums\FuelType; +use App\Enums\PlanTier; use App\Filament\NavigationGroup; use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Resources\UserResource\Pages\ListUsers; +use App\Models\Plan; use App\Models\User; use Filament\Actions\DeleteAction; use Filament\Actions\EditAction; +use Filament\Forms\Components\DateTimePicker; +use Filament\Forms\Components\Select; use Filament\Forms\Components\TextInput; use Filament\Forms\Components\Toggle; use Filament\Resources\Resource; +use Filament\Schemas\Components\Section; use Filament\Schemas\Schema; use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Table; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Str; class UserResource extends Resource { @@ -28,12 +36,89 @@ class UserResource extends Resource public static function form(Schema $schema): Schema { return $schema->components([ - Toggle::make('is_admin') - ->label('Admin') - ->helperText('Grants access to this admin panel.'), - TextInput::make('postcode') - ->label('Postcode') - ->maxLength(8), + Section::make('Profile')->columns(2)->schema([ + TextInput::make('name') + ->required() + ->maxLength(255), + TextInput::make('email') + ->email() + ->required() + ->maxLength(255), + TextInput::make('postcode') + ->maxLength(8), + Select::make('preferred_fuel_type') + ->label('Preferred fuel type') + ->options(collect(FuelType::cases()) + ->mapWithKeys(fn (FuelType $t) => [$t->value => $t->label()]) + ->all()) + ->required(), + ]), + + Section::make('Access')->columns(2)->schema([ + Toggle::make('is_admin') + ->label('Admin') + ->helperText('Grants access to this admin panel.'), + DateTimePicker::make('email_verified_at') + ->label('Email verified at'), + ]), + + Section::make('Subscription')->columns(2)->schema([ + Select::make('tier') + ->label('Tier') + ->options([ + PlanTier::Free->value => 'Free', + PlanTier::Basic->value => 'Basic', + PlanTier::Plus->value => 'Plus', + PlanTier::Pro->value => 'Pro', + ]) + ->required() + ->live() + ->dehydrated(false) + ->afterStateHydrated(fn (Select $component, ?User $record) => $component + ->state($record ? Plan::resolveForUser($record)->name : PlanTier::Free->value)), + Select::make('cadence') + ->label('Billing Cadence') + ->options([ + 'monthly' => 'Monthly', + 'annual' => 'Annual', + ]) + ->default('monthly') + ->dehydrated(false) + ->visible(fn (callable $get): bool => ($get('tier') ?? '') !== PlanTier::Free->value) + ->helperText('Only applies when assigning a paid tier. Real Stripe subscriptions are not modified.'), + ]), + + Section::make('Security')->columns(2)->schema([ + DateTimePicker::make('two_factor_confirmed_at') + ->label('2FA confirmed at') + ->disabled(), + TextInput::make('password') + ->label('Set new password') + ->password() + ->revealable() + ->minLength(8) + ->dehydrated(fn (?string $state): bool => filled($state)) + ->dehydrateStateUsing(fn (string $state): string => bcrypt($state)) + ->helperText('Leave blank to keep current password.') + ->afterStateHydrated(fn (TextInput $component) => $component->state(null)), + ]), + + Section::make('Billing')->columns(3)->schema([ + TextInput::make('stripe_id') + ->label('Stripe customer ID') + ->disabled(), + TextInput::make('pm_type') + ->label('Payment method') + ->disabled(), + TextInput::make('pm_last_four') + ->label('Card last 4') + ->disabled(), + ]), + + Section::make('Timestamps')->columns(2)->schema([ + DateTimePicker::make('created_at')->disabled(), + DateTimePicker::make('updated_at')->disabled(), + ]), ]); } @@ -44,6 +129,16 @@ class UserResource extends Resource TextColumn::make('name')->searchable()->sortable(), TextColumn::make('email')->searchable()->sortable(), TextColumn::make('postcode')->placeholder('—'), + TextColumn::make('tier') + ->label('Tier') + ->state(fn (User $record): string => Plan::resolveForUser($record)->name) + ->badge() + ->colors([ + 'gray' => 'free', + 'primary' => 'basic', + 'warning' => 'plus', + 'success' => 'pro', + ]), IconColumn::make('is_admin') ->label('Admin') ->boolean(), @@ -62,6 +157,53 @@ class UserResource extends Resource ]); } + /** + * Cancel any existing admin-granted subscription, then (if a paid tier + * was requested) insert a fresh synthetic active subscription row. + */ + public static function applyTier(User $user, string $tier, string $cadence): void + { + $hasRealStripeSubscription = $user->subscriptions() + ->where('stripe_id', 'not like', 'admin_%') + ->whereIn('stripe_status', ['active', 'trialing', 'past_due']) + ->exists(); + + if ($hasRealStripeSubscription) { + throw new \RuntimeException( + "User {$user->email} has an active Stripe subscription — modify it through the Stripe dashboard, not the admin panel." + ); + } + + $user->subscriptions()->where('stripe_id', 'like', 'admin_%')->delete(); + + if ($tier === PlanTier::Free->value) { + self::bustPlanCache($user); + + return; + } + + $priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?? "price_admin_{$tier}_{$cadence}"; + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'admin_'.Str::uuid(), + 'stripe_status' => 'active', + 'stripe_price' => $priceId, + 'quantity' => 1, + ]); + + self::bustPlanCache($user); + } + + protected static function bustPlanCache(User $user): void + { + if (Cache::supportsTags()) { + Cache::tags(['plans'])->flush(); + } else { + Cache::forget("plan_for_user_{$user->id}"); + } + } + public static function getPages(): array { return [ diff --git a/app/Filament/Resources/UserResource/Pages/EditUser.php b/app/Filament/Resources/UserResource/Pages/EditUser.php index 5b1aa09..f298036 100644 --- a/app/Filament/Resources/UserResource/Pages/EditUser.php +++ b/app/Filament/Resources/UserResource/Pages/EditUser.php @@ -3,14 +3,52 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; +use App\Filament\Resources\UserResource\Widgets\MissedNotificationsOverview; +use App\Models\User; +use Filament\Notifications\Notification; use Filament\Resources\Pages\EditRecord; class EditUser extends EditRecord { protected static string $resource = UserResource::class; + protected function getHeaderWidgets(): array + { + return [ + MissedNotificationsOverview::class, + ]; + } + + public function getHeaderWidgetsColumns(): int|array + { + return 3; + } + protected function getHeaderActions(): array { return []; } + + protected function afterSave(): void + { + /** @var User $user */ + $user = $this->record; + + $tier = $this->data['tier'] ?? null; + $cadence = $this->data['cadence'] ?? 'monthly'; + + if ($tier === null) { + return; + } + + try { + UserResource::applyTier($user, $tier, $cadence); + } catch (\RuntimeException $e) { + Notification::make() + ->title('Tier not changed') + ->body($e->getMessage()) + ->warning() + ->send(); + } + } } diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index 0862b2b..036b402 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use App\Models\Plan; use App\Models\User; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -61,6 +62,11 @@ class AuthController extends Controller public function me(Request $request): JsonResponse { - return response()->json($request->user()); + $user = $request->user(); + + return response()->json(array_merge( + $user->toArray(), + ['tier' => Plan::resolveForUser($user)->name], + )); } } diff --git a/app/Http/Controllers/Api/StationController.php b/app/Http/Controllers/Api/StationController.php index 8805af2..70bfe5e 100644 --- a/app/Http/Controllers/Api/StationController.php +++ b/app/Http/Controllers/Api/StationController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers\Api; -use App\Enums\PriceClassification; +use App\Enums\PriceReliability; use App\Http\Controllers\Controller; use App\Http\Requests\Api\NearbyStationsRequest; use App\Http\Resources\Api\StationResource; @@ -57,12 +57,20 @@ class StationController extends Controller $filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius); $stations = $sort === 'reliable' - ? $filtered->sortBy([ - fn ($s) => PriceClassification::fromUpdatedAt( - $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null - )->weight(), - fn ($s) => (int) $s->price_pence, - ])->values() + ? $filtered + ->sort(function ($a, $b) { + $weightA = PriceReliability::fromUpdatedAt( + $a->price_effective_at ? Carbon::parse($a->price_effective_at) : null + )->weight(); + $weightB = PriceReliability::fromUpdatedAt( + $b->price_effective_at ? Carbon::parse($b->price_effective_at) : null + )->weight(); + + return $weightA <=> $weightB + ?: ((int) $a->price_pence <=> (int) $b->price_pence) + ?: ((float) $a->distance_km <=> (float) $b->distance_km); + }) + ->values() : $filtered->sortBy(match ($sort) { 'price' => fn ($s) => (int) $s->price_pence, 'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX, @@ -72,6 +80,12 @@ class StationController extends Controller $prices = $stations->pluck('price_pence'); + $reliabilityCounts = $stations + ->groupBy(fn ($s) => PriceReliability::fromUpdatedAt( + $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null + )->value) + ->map->count(); + Search::create([ 'lat_bucket' => round($lat, 2), 'lng_bucket' => round($lng, 2), @@ -96,6 +110,11 @@ class StationController extends Controller 'highest_pence' => $prices->max(), 'cheapest_price_pence' => $prices->min(), 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + 'reliability_counts' => [ + 'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0), + 'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0), + 'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0), + ], ], ]); } diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index ed07d52..ae3da37 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Enums\FuelType; use App\Http\Controllers\Controller; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -25,7 +26,7 @@ final class UserController extends Controller public function updatePreferences(Request $request): JsonResponse { $validated = $request->validate([ - 'preferred_fuel_type' => ['sometimes', Rule::in(['petrol', 'diesel', 'e5', 'b7_premium', 'b10', 'hvo'])], + 'preferred_fuel_type' => ['sometimes', Rule::in(array_column(FuelType::cases(), 'value'))], 'postcode' => ['sometimes', 'string', 'max:8'], ]); diff --git a/app/Http/Requests/Api/NearbyStationsRequest.php b/app/Http/Requests/Api/NearbyStationsRequest.php index 5bc80c7..0f534a8 100644 --- a/app/Http/Requests/Api/NearbyStationsRequest.php +++ b/app/Http/Requests/Api/NearbyStationsRequest.php @@ -27,7 +27,7 @@ class NearbyStationsRequest extends FormRequest public function fuelType(): FuelType { - return FuelType::fromAlias($this->string('fuel_type')->toString()); + return FuelType::from(strtolower($this->string('fuel_type')->toString())); } public function radius(): float @@ -37,6 +37,6 @@ class NearbyStationsRequest extends FormRequest public function sort(): string { - return $this->input('sort', 'price'); + return $this->input('sort', 'reliable'); } } diff --git a/app/Http/Resources/Api/StationResource.php b/app/Http/Resources/Api/StationResource.php index 0fafe81..605645b 100644 --- a/app/Http/Resources/Api/StationResource.php +++ b/app/Http/Resources/Api/StationResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Api; use App\Enums\PriceClassification; +use App\Enums\PriceReliability; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Carbon; @@ -11,6 +12,9 @@ class StationResource extends JsonResource { public function toArray(Request $request): array { + $updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null; + $reliability = PriceReliability::fromUpdatedAt($updatedAt); + return [ 'station_id' => $this->node_id, 'name' => $this->trading_name, @@ -27,12 +31,10 @@ class StationResource extends JsonResource 'price_updated_at' => $this->price_effective_at ? Carbon::parse($this->price_effective_at)->toISOString() : null, - 'price_classification' => PriceClassification::fromUpdatedAt( - $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null - )->value, - 'price_classification_label' => PriceClassification::fromUpdatedAt( - $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null - )->label(), + 'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value, + 'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(), + 'reliability' => $reliability->value, + 'reliability_label' => $reliability->label(), ]; } } diff --git a/app/Models/Plan.php b/app/Models/Plan.php index e9091c7..99b0ead 100644 --- a/app/Models/Plan.php +++ b/app/Models/Plan.php @@ -15,7 +15,8 @@ class Plan extends Model protected $fillable = [ 'name', - 'stripe_price_id', + 'stripe_price_id_monthly', + 'stripe_price_id_annual', 'features', 'active', ]; @@ -28,10 +29,10 @@ class Plan extends Model { $cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store(); - return $cache->remember( + $planId = $cache->remember( "plan_for_user_{$user->id}", 3600, - function () use ($user): self { + function () use ($user): ?int { $priceId = null; if (method_exists($user, 'subscriptions')) { @@ -40,16 +41,43 @@ class Plan extends Model } if ($priceId) { - $plan = static::where('stripe_price_id', $priceId)->where('active', true)->first(); + $plan = static::where(fn ($q) => $q + ->where('stripe_price_id_monthly', $priceId) + ->orWhere('stripe_price_id_annual', $priceId)) + ->where('active', true) + ->first(); if ($plan) { - return $plan; + return $plan->id; } } - return static::where('name', PlanTier::Free->value)->firstOrFail(); + return static::where('name', PlanTier::Free->value)->value('id'); } ); + + if ($planId !== null) { + $plan = static::find($planId); + + if ($plan !== null) { + return $plan; + } + } + + // Fallback for tests / partially-seeded environments: return a free-tier stub. + return new self([ + 'name' => PlanTier::Free->value, + '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, + ], + ]); } protected static function booted(): void diff --git a/app/Models/User.php b/app/Models/User.php index 529800c..f42e9e3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,15 +13,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; +use Laravel\Cashier\Billable; use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Sanctum\HasApiTokens; -#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])] +#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements FilamentUser { /** @use HasFactory */ - use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; + use Billable, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 242a763..792bda2 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Listeners\DowngradeUserOnSubscriptionDeleted; use App\Services\ApiLogger; use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Services\LlmPrediction\GeminiPredictionProvider; @@ -10,8 +11,10 @@ use App\Services\LlmPrediction\OpenAiPredictionProvider; use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; +use Laravel\Cashier\Events\WebhookReceived; class AppServiceProvider extends ServiceProvider { @@ -37,6 +40,8 @@ class AppServiceProvider extends ServiceProvider public function boot(): void { $this->configureDefaults(); + + Event::listen(WebhookReceived::class, DowngradeUserOnSubscriptionDeleted::class); } /** diff --git a/app/Services/ApiLogger.php b/app/Services/ApiLogger.php index 2cda8c6..39ef8fa 100644 --- a/app/Services/ApiLogger.php +++ b/app/Services/ApiLogger.php @@ -4,6 +4,7 @@ namespace App\Services; use App\Models\ApiLog; use Illuminate\Http\Client\Response; +use Illuminate\Support\Str; use Throwable; class ApiLogger @@ -26,6 +27,10 @@ class ApiLogger $response = $request(); $statusCode = $response->status(); + if ($response->failed()) { + $error = Str::limit($response->body(), 1000); + } + return $response; } catch (Throwable $e) { $error = $e->getMessage(); diff --git a/app/Services/FuelPriceService.php b/app/Services/FuelPriceService.php index d5c8842..c1baeaa 100644 --- a/app/Services/FuelPriceService.php +++ b/app/Services/FuelPriceService.php @@ -6,6 +6,7 @@ use App\Enums\FuelType; use App\Models\Station; use App\Models\StationPrice; use App\Models\StationPriceCurrent; +use Carbon\CarbonInterface; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -17,6 +18,8 @@ class FuelPriceService { private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token'; + private const string LAST_PRICE_POLL_CACHE_KEY = 'fuel_finder_last_price_poll_at'; + /** * Per-fuel-type valid price range in pence (as returned by the API). * Based on UK all-time records + 30–75% headroom for future spikes. @@ -55,6 +58,10 @@ class FuelPriceService /** * Poll the prices endpoint, deduplicate, and persist changes. * + * Uses incremental polling when a previous poll timestamp is cached — only + * stations with prices changed since then are returned by the API. Falls + * back to a full fetch on cold start (cache miss). + * * @return int Number of new price records inserted */ public function pollPrices(): int @@ -62,18 +69,27 @@ class FuelPriceService $token = $this->getAccessToken(); $inserted = 0; $batch = 1; + $pollStartedAt = now(); + $since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY); + $completedCleanly = false; do { try { $baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices'; $params = ['batch-number' => $batch]; + + if ($since instanceof CarbonInterface) { + $params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s'); + } + $logUrl = $baseUrl.'?'.http_build_query($params); $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) ->withToken($token) ->get($baseUrl, $params)); if ($response->notFound()) { - break; // No more batches + $completedCleanly = true; + break; } if (! $response->successful()) { @@ -94,6 +110,7 @@ class FuelPriceService } if (empty($stations)) { + $completedCleanly = true; break; } @@ -101,6 +118,10 @@ class FuelPriceService $batch++; } while (true); + if ($completedCleanly) { + Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt); + } + return $inserted; } @@ -159,6 +180,14 @@ class FuelPriceService $rows = []; foreach ($apiStations as $data) { + if (! $this->hasRequiredStationFields($data)) { + Log::warning('FuelPriceService: station skipped — missing required fields', [ + 'node_id' => $data['node_id'] ?? null, + ]); + + continue; + } + $station = new Station([ 'node_id' => $data['node_id'], 'trading_name' => $data['trading_name'], @@ -179,9 +208,9 @@ class FuelPriceService 'postcode' => $data['location']['postcode'], 'lat' => $data['location']['latitude'], 'lng' => $data['location']['longitude'], - 'amenities' => $data['amenities'] ?? [], + 'amenities' => self::flattenEnabledFlags($data['amenities'] ?? []), 'opening_times' => $data['opening_times'] ?? null, - 'fuel_types' => $data['fuel_types'] ?? [], + 'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []), 'last_seen_at' => $now, ]); @@ -189,7 +218,40 @@ class FuelPriceService $rows[] = $station->getAttributes(); } - Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? [])); + if ($rows === []) { + return; + } + + Station::upsert($rows, ['node_id'], array_keys($rows[0])); + } + + /** @param array $data */ + private function hasRequiredStationFields(array $data): bool + { + return ! empty($data['node_id']) + && ! empty($data['trading_name']) + && isset($data['location']['postcode'], $data['location']['latitude'], $data['location']['longitude']); + } + + /** + * The API returns `amenities` and `fuel_types` as objects with boolean + * flags (e.g. {"E10": true, "car_wash": false}). Flatten to a list of + * enabled keys. If the payload is already an array of strings, return as-is. + * + * @param array|array $flags + * @return array + */ + private static function flattenEnabledFlags(array $flags): array + { + if ($flags === []) { + return []; + } + + if (array_is_list($flags)) { + return array_values($flags); + } + + return array_values(array_keys(array_filter($flags, fn ($v) => filter_var($v, FILTER_VALIDATE_BOOLEAN)))); } private function isValidPrice(FuelType $fuelType, float $pricePence): bool @@ -216,8 +278,22 @@ class FuelPriceService { $stationIds = array_column($apiBatch, 'node_id'); + // Filter to stations that exist in the stations table — prevents FK + // violations when the API surfaces a station before the next metadata + // refresh picks it up. + $knownStationIds = array_flip( + Station::whereIn('node_id', $stationIds)->pluck('node_id')->all(), + ); + + $unknown = array_diff($stationIds, array_keys($knownStationIds)); + if ($unknown !== []) { + Log::info('FuelPriceService: skipped prices for unknown stations', [ + 'count' => count($unknown), + ]); + } + // Load current prices for all stations in this batch in one query - $currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds) + $currentPrices = StationPriceCurrent::whereIn('station_id', array_keys($knownStationIds)) ->get() ->groupBy('station_id') ->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value)); @@ -227,9 +303,17 @@ class FuelPriceService $upsertRows = []; foreach ($apiBatch as $station) { - $stationId = $station['node_id']; + $stationId = $station['node_id'] ?? null; + + if ($stationId === null || ! isset($knownStationIds[$stationId])) { + continue; + } foreach ($station['fuel_prices'] ?? [] as $priceData) { + if (! isset($priceData['fuel_type'], $priceData['price'], $priceData['price_last_updated'], $priceData['price_change_effective_timestamp'])) { + continue; + } + try { $fuelType = FuelType::fromApiValue($priceData['fuel_type']); } catch (ValueError) { diff --git a/database/seeders/PlanSeeder.php b/database/seeders/PlanSeeder.php index 6c21fcf..b7be9eb 100644 --- a/database/seeders/PlanSeeder.php +++ b/database/seeders/PlanSeeder.php @@ -12,7 +12,8 @@ class PlanSeeder extends Seeder { $plans = [ PlanTier::Free->value => [ - 'stripe_price_id' => null, + 'stripe_price_id_monthly' => null, + 'stripe_price_id_annual' => null, 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'weekly_digest'], @@ -25,7 +26,8 @@ class PlanSeeder extends Seeder ], ], PlanTier::Basic->value => [ - 'stripe_price_id' => config('services.stripe.prices.basic'), + 'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'), + 'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'), 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'daily'], @@ -38,7 +40,8 @@ class PlanSeeder extends Seeder ], ], PlanTier::Plus->value => [ - 'stripe_price_id' => config('services.stripe.prices.plus'), + 'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'), + 'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'), 'features' => [ 'fuel_types' => ['max' => 1], 'email' => ['enabled' => true, 'frequency' => 'triggered'], @@ -51,7 +54,8 @@ class PlanSeeder extends Seeder ], ], PlanTier::Pro->value => [ - 'stripe_price_id' => config('services.stripe.prices.pro'), + 'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'), + 'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'), 'features' => [ 'fuel_types' => ['max' => null], 'email' => ['enabled' => true, 'frequency' => 'triggered'], @@ -69,7 +73,8 @@ class PlanSeeder extends Seeder Plan::updateOrCreate( ['name' => $name], [ - 'stripe_price_id' => $data['stripe_price_id'], + 'stripe_price_id_monthly' => $data['stripe_price_id_monthly'], + 'stripe_price_id_annual' => $data['stripe_price_id_annual'], 'features' => $data['features'], 'active' => true, ] diff --git a/resources/js/components/SearchBar.vue b/resources/js/components/SearchBar.vue index 99992bd..b381017 100644 --- a/resources/js/components/SearchBar.vue +++ b/resources/js/components/SearchBar.vue @@ -41,12 +41,9 @@ aria-label="Fuel type" class="min-w-0 h-10 px-2 bg-white border border-zinc-300 rounded-xl text-sm font-medium text-zinc-700 focus:outline-none focus:ring-2 focus:ring-accent truncate" > - - - - - - + @@ -75,13 +72,14 @@ diff --git a/resources/js/views/Home.vue b/resources/js/views/Home.vue index 8607b5c..57d56aa 100644 --- a/resources/js/views/Home.vue +++ b/resources/js/views/Home.vue @@ -220,7 +220,25 @@

Pricing for every driver

-

Save hundreds for less than the cost of a coffee.

+

Save hundreds for less than the cost of a coffee.

+
+ + +
@@ -238,7 +256,7 @@
  • Daily Updates
  • No Alerts
  • - Get Started + {{ ctaLabel('free') }}
    @@ -246,8 +264,8 @@

    Basic

    - £0.99 - /mo + {{ PRICES[cadence].basic }} + {{ PRICE_SUFFIX[cadence] }}
      @@ -255,7 +273,7 @@
    • 14-day Trend Data
    • 3 Daily Price Alerts
    - Select Basic + {{ ctaLabel('basic') }}
    @@ -264,8 +282,8 @@

    Plus

    - £2.49 - /mo + {{ PRICES[cadence].plus }} + {{ PRICE_SUFFIX[cadence] }}
      @@ -273,7 +291,7 @@
    • Priority Price Alerts
    • Multi-location tracking
    - Join Plus + {{ ctaLabel('plus') }} @@ -281,8 +299,8 @@

    Pro

    - £3.99 - /mo + {{ PRICES[cadence].pro }} + {{ PRICE_SUFFIX[cadence] }}
      @@ -290,7 +308,7 @@
    • Multi-Vehicle Fleet
    • Exportable Price History
    - Go Pro + {{ ctaLabel('pro') }} @@ -417,10 +435,45 @@ import SearchBar from '../components/SearchBar.vue' import LeafletMap from '../components/LeafletMap.vue' import StationList from '../components/StationList.vue' -const { isAuthenticated } = useAuth() +const { isAuthenticated, userTier } = useAuth() + +const cadence = ref('monthly') + +function ctaHref(tier) { + if (tier === 'free') { + return isAuthenticated.value ? '/dashboard' : '/register' + } + if (!isAuthenticated.value) { + return '/register?tier=' + tier + '&cadence=' + cadence.value + } + if (userTier.value === tier) { + return '/billing/portal' + } + return '/billing/checkout/' + tier + '/' + cadence.value +} + +function ctaLabel(tier) { + if (tier === 'free') { + return isAuthenticated.value ? 'Go to dashboard' : 'Get started' + } + if (isAuthenticated.value && userTier.value === tier) { + return 'Manage subscription' + } + return { + basic: 'Select Basic', + plus: 'Join Plus', + pro: 'Go Pro', + }[tier] +} + +const PRICES = { + monthly: { basic: '£0.99', plus: '£2.49', pro: '£3.99' }, + annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' }, +} +const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' } const { stations, loading, error, search } = useStations() -const sort = ref('price') +const sort = ref('reliable') const lastParams = ref(null) const searchAttempted = ref(false) const radiusMiles = ref(10) diff --git a/resources/js/views/dashboard/Preferences.vue b/resources/js/views/dashboard/Preferences.vue index a133975..0abce46 100644 --- a/resources/js/views/dashboard/Preferences.vue +++ b/resources/js/views/dashboard/Preferences.vue @@ -9,12 +9,9 @@ v-model="form.preferred_fuel_type" class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent" > - - - - - - + @@ -46,14 +43,15 @@ @vite(['resources/css/app.css', 'resources/js/app.js']) diff --git a/resources/views/components/fuel/type-select.blade.php b/resources/views/components/fuel/type-select.blade.php deleted file mode 100644 index a1f239a..0000000 --- a/resources/views/components/fuel/type-select.blade.php +++ /dev/null @@ -1,33 +0,0 @@ -
    whereStartsWith('wire:model') }} -> - - - Select fuel type - - - - Petrol (E10) - Super Unleaded (E5) - Diesel - Premium Diesel - B10 Biodiesel - HVO - - -
    diff --git a/routes/api.php b/routes/api.php index adaed35..9de5b83 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,17 +1,25 @@ addDay(), fn () => collect(FuelType::cases()) + ->map(fn (FuelType $case) => ['value' => $case->value, 'label' => $case->label()]) + ->values()); +}); + // Protected endpoints (API key required) Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void { Route::get('/stations', [StationController::class, 'index']); diff --git a/routes/console.php b/routes/console.php index 494652c..0f6ce57 100644 --- a/routes/console.php +++ b/routes/console.php @@ -9,14 +9,17 @@ Artisan::command('inspire', function () { $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -// Poll for price changes every 15 minutes +// Poll for price changes every 30 minutes — API updates within 30 min of any +// change. The command auto-refreshes station metadata once per day on the +// first poll after midnight, and uses incremental fetch thereafter. Schedule::command('fuel:poll') - ->everyFifteenMinutes() + ->everyThirtyMinutes() ->withoutOverlapping() ->onOneServer() ->runInBackground(); -// Full refresh (station metadata + prices) once daily at 3am +// Safety-net full station + price refresh at 3am in case the auto-refresh +// staleness check is skipped for any reason. Schedule::command('fuel:poll --full') ->dailyAt('03:00') ->withoutOverlapping() diff --git a/routes/web.php b/routes/web.php index 6e0121e..74e2a13 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ middleware('auth')->name('logout'); +Route::middleware(['auth'])->prefix('billing')->name('billing.')->group(function () { + Route::get('/checkout/{tier}/{cadence}', [BillingController::class, 'checkout'])->name('checkout'); + Route::get('/portal', [BillingController::class, 'portal'])->name('portal'); + Route::get('/success', [BillingController::class, 'success'])->name('success'); + Route::get('/cancel', [BillingController::class, 'cancel'])->name('cancel'); +}); + // SPA catch-all — must be last Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home'); diff --git a/tests/Feature/Api/StationControllerTest.php b/tests/Feature/Api/StationControllerTest.php index a1f8edc..3bbec22 100644 --- a/tests/Feature/Api/StationControllerTest.php +++ b/tests/Feature/Api/StationControllerTest.php @@ -20,7 +20,7 @@ it('returns stations near coordinates filtered by fuel type', function () { 'price_pence' => 14500, ]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10&sort=price') + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10&sort=price') ->assertOk() ->assertJsonStructure([ 'data' => [['station_id', 'name', 'brand', 'is_supermarket', 'lat', 'lng', 'distance_km', 'fuel_type', 'price_pence', 'price', 'price_updated_at']], @@ -38,7 +38,7 @@ it('excludes stations with no matching fuel type', function () { 'price_pence' => 13800, ]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10') ->assertOk() ->assertJsonPath('meta.count', 0); }); @@ -54,7 +54,7 @@ it('excludes temporarily closed stations', function () { 'price_pence' => 14200, ]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10') ->assertOk() ->assertJsonPath('meta.count', 0); }); @@ -68,7 +68,7 @@ it('excludes stations beyond radius', function () { 'price_pence' => 14200, ]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10') ->assertOk() ->assertJsonPath('meta.count', 0); }); @@ -82,7 +82,7 @@ it('sorts by price when sort=price', function () { StationPriceCurrent::factory()->create(['station_id' => $cheap->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 13900]); StationPriceCurrent::factory()->create(['station_id' => $expensive->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]); - $this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=diesel&radius=10&sort=price") + $this->getJson("/api/stations?lat={$sLat}&lng={$sLng}&fuel_type=b7_standard&radius=10&sort=price") ->assertOk() ->assertJsonPath('data.0.price_pence', 13900); }); @@ -91,7 +91,7 @@ it('logs a search record for each request', function () { $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10'); + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10'); $this->assertDatabaseHas('searches', [ 'lat_bucket' => '52.56', @@ -166,7 +166,7 @@ it('includes resolved lat and lng in meta', function () { 'price_pence' => 14500, ]); - $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10') ->assertOk() ->assertJsonPath('meta.lat', 52.555064) ->assertJsonPath('meta.lng', -0.256119); diff --git a/tests/Feature/Api/UserControllerTest.php b/tests/Feature/Api/UserControllerTest.php index 91a0dea..6698fb6 100644 --- a/tests/Feature/Api/UserControllerTest.php +++ b/tests/Feature/Api/UserControllerTest.php @@ -5,23 +5,23 @@ use Illuminate\Support\Facades\Hash; use Laravel\Sanctum\Sanctum; it('returns user preferences for authenticated user', function (): void { - $user = User::factory()->create(['preferred_fuel_type' => 'diesel']); + $user = User::factory()->create(['preferred_fuel_type' => 'b7_standard']); Sanctum::actingAs($user); $this->getJson('/api/user/preferences') ->assertOk() - ->assertJsonFragment(['preferred_fuel_type' => 'diesel']); + ->assertJsonFragment(['preferred_fuel_type' => 'b7_standard']); }); it('updates user preferences', function (): void { - $user = User::factory()->create(['preferred_fuel_type' => 'petrol']); + $user = User::factory()->create(['preferred_fuel_type' => 'e10']); Sanctum::actingAs($user); - $this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'diesel']) + $this->putJson('/api/user/preferences', ['preferred_fuel_type' => 'b7_standard']) ->assertOk() - ->assertJsonFragment(['preferred_fuel_type' => 'diesel']); + ->assertJsonFragment(['preferred_fuel_type' => 'b7_standard']); - expect($user->fresh()->preferred_fuel_type)->toBe('diesel'); + expect($user->fresh()->preferred_fuel_type)->toBe('b7_standard'); }); it('rejects invalid fuel type in preferences update', function (): void { diff --git a/tests/Feature/Livewire/Fuel/SearchTest.php b/tests/Feature/Livewire/Fuel/SearchTest.php deleted file mode 100644 index 27af670..0000000 --- a/tests/Feature/Livewire/Fuel/SearchTest.php +++ /dev/null @@ -1,217 +0,0 @@ -assertStatus(200) - ->assertSeeHtml('name="search"'); -}); - -it('has default property values', function () { - Livewire::test(Search::class) - ->assertSet('search', '') - ->assertSet('fuelType', 'petrol') - ->assertSet('radius', 5) - ->assertSet('sort', 'reliable') - ->assertSet('apiError', null) - ->assertSet('hasSearched', false); -}); - -it('validates search is required', function () { - Livewire::test(Search::class) - ->call('findStations') - ->assertHasErrors(['search' => 'required']); -}); - -it('validates fuelType is required', function () { - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', '') - ->call('findStations') - ->assertHasErrors(['fuelType' => 'required']); -}); - -it('dispatches stations-found with results, meta, prediction and radius on successful search', function () { - Http::fake([ - '*/api/stations*' => Http::response([ - 'data' => [ - [ - 'station_id' => 'abc123', - 'name' => 'BP Garage', - 'brand' => 'BP', - 'is_supermarket' => false, - 'address' => '1 High Street', - 'postcode' => 'SW1A 1AA', - 'lat' => 51.5074, - 'lng' => -0.1278, - 'distance_km' => 1.5, - 'fuel_type' => 'e10', - 'price_pence' => 14390, - 'price' => 143.9, - 'price_updated_at' => '2026-04-05T08:00:00.000Z', - 'price_classification' => 'current', - 'price_classification_label' => 'Current', - ], - ], - 'meta' => ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0], - ], 200), - '*/api/prediction*' => Http::response([ - 'action' => 'fill_now', - 'confidence_score' => 80.0, - 'confidence_label' => 'high', - 'reasoning' => 'Prices rising.', - 'predicted_direction' => 'up', - 'predicted_change_pence' => 3.5, - ], 200), - ]); - - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('hasSearched', true) - ->assertSet('apiError', null) - ->assertDispatched('stations-found', fn ($event, $params) => - count($params['results']) === 1 - && $params['results'][0]['name'] === 'BP Garage' - && $params['meta']['count'] === 1 - && $params['prediction']['action'] === 'fill_now' - && $params['radius'] === 5 - ); -}); - -it('sets apiError from 422 station response and does not dispatch stations-found', function () { - Http::fake([ - '*/api/stations*' => Http::response([ - 'errors' => ['postcode' => ['Postcode not found.']], - ], 422), - ]); - - Livewire::test(Search::class) - ->set('search', 'ZZ99 9ZZ') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('hasSearched', false) - ->assertSet('apiError', 'Postcode not found.') - ->assertNotDispatched('stations-found'); -}); - -it('sets generic apiError on server error', function () { - Http::fake([ - '*/api/stations*' => Http::response([], 500), - ]); - - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('apiError', 'Unable to fetch stations. Please try again.'); -}); - -it('converts radius from miles to km in the outgoing stations request', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), - ]); - - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->set('radius', 5) - ->call('findStations'); - - Http::assertSent(function ($request) { - if (! str_contains($request->url(), 'api/stations')) { - return false; - } - $data = $request->data(); - return isset($data['radius']) && abs((float) $data['radius'] - 8.05) < 0.01; - }); -}); - -it('resets apiError before each new search', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), - ]); - - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->set('apiError', 'Old error') - ->call('findStations') - ->assertSet('apiError', null); -}); - -it('does not call findStations on updatedFuelType if not yet searched', function () { - Http::fake(); - - Livewire::test(Search::class) - ->set('fuelType', 'diesel'); - - Http::assertNothingSent(); -}); - -it('re-runs findStations on updatedFuelType when already searched', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), - ]); - - Livewire::test(Search::class) - ->set('hasSearched', true) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'diesel'); - - Http::assertSentCount(2); -}); - -it('re-runs findStations on updatedRadius when already searched', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), - ]); - - Livewire::test(Search::class) - ->set('hasSearched', true) - ->set('search', 'SW1A 1AA') - ->set('radius', 10); - - Http::assertSentCount(2); -}); - -it('re-runs findStations on updatedSort when already searched', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response(['action' => 'no_signal', 'confidence_score' => 0, 'confidence_label' => 'low', 'reasoning' => '', 'predicted_direction' => 'stable', 'predicted_change_pence' => 0], 200), - ]); - - Livewire::test(Search::class) - ->set('hasSearched', true) - ->set('search', 'SW1A 1AA') - ->set('sort', 'price'); - - Http::assertSentCount(2); -}); - -it('prediction is null in stations-found payload when prediction api fails', function () { - Http::fake([ - '*/api/stations*' => Http::response(['data' => [], 'meta' => ['count' => 0]], 200), - '*/api/prediction*' => Http::response([], 500), - ]); - - Livewire::test(Search::class) - ->set('search', 'SW1A 1AA') - ->set('fuelType', 'petrol') - ->call('findStations') - ->assertSet('hasSearched', true) - ->assertDispatched('stations-found', fn ($event, $params) => - $params['prediction'] === null - ); -}); diff --git a/tests/Feature/VerifyApiKeyMiddlewareTest.php b/tests/Feature/VerifyApiKeyMiddlewareTest.php index fa1dcfa..2ac7140 100644 --- a/tests/Feature/VerifyApiKeyMiddlewareTest.php +++ b/tests/Feature/VerifyApiKeyMiddlewareTest.php @@ -4,7 +4,7 @@ use App\Models\User; use Laravel\Sanctum\Sanctum; it('rejects requests without api key or sanctum session', function (): void { - $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol'); + $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10'); $response->assertStatus(403); }); @@ -13,7 +13,7 @@ it('accepts requests with valid api key', function (): void { config(['app.api_secret_key' => 'test-secret']); $response = $this->withHeader('X-Api-Key', 'test-secret') - ->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol'); + ->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10'); // 403 would mean middleware rejected — any other status means it passed through expect($response->status())->not->toBe(403); @@ -23,7 +23,7 @@ it('accepts requests from sanctum authenticated users', function (): void { $user = User::factory()->create(); Sanctum::actingAs($user); - $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=petrol'); + $response = $this->getJson('/api/stations?postcode=SW1A1AA&fuel_type=e10'); expect($response->status())->not->toBe(403); }); diff --git a/tests/Unit/ApiLoggerTest.php b/tests/Unit/ApiLoggerTest.php index 4af7fd8..1337dba 100644 --- a/tests/Unit/ApiLoggerTest.php +++ b/tests/Unit/ApiLoggerTest.php @@ -40,6 +40,16 @@ it('logs a failed request and re-throws the exception', function (): void { ->and($log->error)->toBe('connection refused'); }); +it('captures response body as error when status is 4xx/5xx', function (): void { + Http::fake(['https://example.com/missing' => Http::response('Not Found', 404)]); + + $this->apiLogger->send('test_service', 'GET', 'https://example.com/missing', fn () => Http::get('https://example.com/missing')); + + $log = ApiLog::first(); + expect($log->status_code)->toBe(404) + ->and($log->error)->toBe('Not Found'); +}); + it('logs a POST request with correct method', function (): void { Http::fake(['https://example.com/token' => Http::response(['token' => 'abc'], 201)]); diff --git a/tests/Unit/Enums/FuelTypeTest.php b/tests/Unit/Enums/FuelTypeTest.php index c7f7261..3f1e7aa 100644 --- a/tests/Unit/Enums/FuelTypeTest.php +++ b/tests/Unit/Enums/FuelTypeTest.php @@ -2,27 +2,22 @@ use App\Enums\FuelType; -it('resolves diesel alias to B7Standard', function () { - expect(FuelType::fromAlias('diesel'))->toBe(FuelType::B7Standard); +it('maps UK API uppercase values to the canonical lowercase enum', function () { + expect(FuelType::fromApiValue('E10'))->toBe(FuelType::E10) + ->and(FuelType::fromApiValue('B7_STANDARD'))->toBe(FuelType::B7Standard) + ->and(FuelType::fromApiValue('HVO'))->toBe(FuelType::Hvo); }); -it('resolves petrol alias to E10', function () { - expect(FuelType::fromAlias('petrol'))->toBe(FuelType::E10); +it('accepts already-lowercase values', function () { + expect(FuelType::fromApiValue('e5'))->toBe(FuelType::E5); }); -it('resolves unleaded alias to E10', function () { - expect(FuelType::fromAlias('unleaded'))->toBe(FuelType::E10); +it('exposes a human label for each case', function () { + expect(FuelType::E10->label())->toBe('Petrol (E10)') + ->and(FuelType::B7Standard->label())->toBe('Diesel (B7)') + ->and(FuelType::Hvo->label())->toBe('HVO'); }); -it('resolves premium_unleaded alias to E5', function () { - expect(FuelType::fromAlias('premium_unleaded'))->toBe(FuelType::E5); -}); - -it('accepts canonical enum values as aliases', function () { - expect(FuelType::fromAlias('e10'))->toBe(FuelType::E10); - expect(FuelType::fromAlias('b7_standard'))->toBe(FuelType::B7Standard); -}); - -it('throws ValueError for unknown alias', function () { - FuelType::fromAlias('avgas'); +it('throws ValueError for unknown fuel types', function () { + FuelType::fromApiValue('avgas'); })->throws(ValueError::class); diff --git a/tests/Unit/Services/FuelPriceServiceTest.php b/tests/Unit/Services/FuelPriceServiceTest.php index f35cb07..8619fe5 100644 --- a/tests/Unit/Services/FuelPriceServiceTest.php +++ b/tests/Unit/Services/FuelPriceServiceTest.php @@ -6,6 +6,7 @@ use App\Models\StationPriceCurrent; use App\Services\ApiLogger; use App\Services\FuelPriceService; use App\Services\StationTaggingService; +use Carbon\CarbonInterface; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; @@ -310,3 +311,110 @@ it('stops pagination when an empty batch is returned', function (): void { Http::assertSentCount(1); }); + +it('caches the poll timestamp and sends it on subsequent polls', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + Cache::forget('fuel_finder_last_price_poll_at'); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::response([]), + ]); + + $this->service->pollPrices(); + + expect(Cache::get('fuel_finder_last_price_poll_at'))->toBeInstanceOf(CarbonInterface::class); + + $this->service->pollPrices(); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'effective-start-timestamp=')); +}); + +it('does not cache the poll timestamp when a batch errors', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + Cache::forget('fuel_finder_last_price_poll_at'); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::response([], 500), + ]); + + $this->service->pollPrices(); + + expect(Cache::has('fuel_finder_last_price_poll_at'))->toBeFalse(); +}); + +it('skips price rows for stations not present in the stations table', function (): void { + Cache::put('fuel_finder_access_token', 'tok', 3540); + + Http::fake([ + '*/pfs/fuel-prices*' => Http::sequence() + ->push([[ + 'node_id' => 'unknown-station', + '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([]), + ]); + + $inserted = $this->service->pollPrices(); + + expect($inserted)->toBe(0) + ->and(StationPrice::count())->toBe(0) + ->and(StationPriceCurrent::count())->toBe(0); +}); + +it('normalises amenities and fuel_types object payloads to flat arrays', function (): void { + $apiStations = [[ + 'node_id' => 'abc999', + 'trading_name' => 'Shell Somewhere', + 'brand_name' => 'Shell', + 'is_same_trading_and_brand_name' => false, + 'is_motorway_service_station' => false, + 'is_supermarket_service_station' => false, + 'temporary_closure' => false, + 'permanent_closure' => false, + 'location' => [ + 'postcode' => 'AB1 2CD', + 'latitude' => 52.1, + 'longitude' => -1.2, + ], + 'amenities' => [ + 'adblue_pumps' => true, + 'car_wash' => false, + 'customer_toilets' => true, + ], + 'fuel_types' => [ + 'E10' => true, + 'E5' => true, + 'B7_Standard' => true, + 'B7_Premium' => false, + 'B10' => false, + 'HVO' => false, + ], + ]]; + + $this->service->upsertStations($apiStations); + + $station = Station::find('abc999'); + expect($station->amenities)->toBe(['adblue_pumps', 'customer_toilets']) + ->and($station->fuel_types)->toBe(['E10', 'E5', 'B7_Standard']); +}); + +it('skips stations missing required fields', function (): void { + $apiStations = [ + ['node_id' => 'missing-loc', 'trading_name' => 'Bad Data'], + [ + 'node_id' => 'good', + 'trading_name' => 'Good Station', + 'location' => ['postcode' => 'AB1 2CD', 'latitude' => 52.0, 'longitude' => -1.0], + ], + ]; + + $this->service->upsertStations($apiStations); + + expect(Station::find('missing-loc'))->toBeNull() + ->and(Station::find('good'))->not->toBeNull(); +});