From c8bc2836b67432b31329b0f83be58e7950c93c82 Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Fri, 3 Apr 2026 18:21:51 +0100 Subject: [PATCH] docs: add fuel API ingestion implementation plan --- .../plans/2026-04-03-fuel-api-ingestion.md | 1686 +++++++++++++++++ 1 file changed, 1686 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md diff --git a/docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md b/docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md new file mode 100644 index 0000000..ae5f535 --- /dev/null +++ b/docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md @@ -0,0 +1,1686 @@ +# Fuel API Ingestion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the full UK Fuel Finder API ingestion pipeline — OAuth token caching, station metadata upsert, price change detection, historic storage, and scheduled polling. + +**Architecture:** `FuelPriceService` handles all API communication and DB writes. `StationTaggingService` classifies supermarket stations. Two console commands drive polling (`PollFuelPricesCommand`) and archive rotation (`ArchiveOldPricesCommand`). A `PricesUpdatedEvent` is fired after each poll for downstream use. + +**Tech Stack:** Laravel 13, PHP 8.4, MySQL (InnoDB + monthly partitions), Pest 4, Laravel HTTP client, Laravel Cache (database driver), Laravel Scheduler. + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `config/services.php` | Modify | Add `fuel_finder` config block | +| `app/Enums/FuelType.php` | Create | Backed enum for fuel types | +| `database/migrations/*_create_stations_table.php` | Create | Station metadata table | +| `database/migrations/*_create_station_prices_current_table.php` | Create | Latest price per station+fuel | +| `database/migrations/*_create_station_prices_table.php` | Create | Partitioned price history | +| `database/migrations/*_create_station_prices_archive_table.php` | Create | Archive for prices > 1 year | +| `app/Models/Station.php` | Create | Eloquent model | +| `app/Models/StationPriceCurrent.php` | Create | Eloquent model | +| `app/Models/StationPrice.php` | Create | Eloquent model | +| `app/Models/StationPriceArchive.php` | Create | Eloquent model | +| `database/factories/StationFactory.php` | Create | Test factory | +| `database/factories/StationPriceCurrentFactory.php` | Create | Test factory | +| `database/factories/StationPriceFactory.php` | Create | Test factory | +| `app/Services/StationTaggingService.php` | Create | Supermarket brand detection | +| `app/Services/FuelPriceService.php` | Create | API client + ingestion logic | +| `app/Events/PricesUpdatedEvent.php` | Create | Fired after each poll | +| `app/Console/Commands/PollFuelPricesCommand.php` | Create | 15-min incremental + daily full | +| `app/Console/Commands/ArchiveOldPricesCommand.php` | Create | Move old rows to archive | +| `bootstrap/app.php` | Modify | Register scheduler | +| `tests/Unit/Services/StationTaggingServiceTest.php` | Create | Unit tests | +| `tests/Unit/Services/FuelPriceServiceTest.php` | Create | Unit tests with Http::fake() | +| `tests/Feature/Commands/PollFuelPricesCommandTest.php` | Create | Command integration tests | +| `tests/Feature/Commands/ArchiveOldPricesCommandTest.php` | Create | Archive command tests | + +--- + +## Task 1: Add fuel_finder config + +**Files:** +- Modify: `config/services.php` + +- [ ] **Step 1: Add config block** + +In `config/services.php`, add before the closing `]`: + +```php +'fuel_finder' => [ + 'base_url' => env('FUEL_FINDER_BASE_URL', 'https://www.fuel-finder.service.gov.uk/api/v1'), + 'client_id' => env('FUEL_FINDER_CLIENT_ID'), + 'client_secret' => env('FUEL_FINDER_CLIENT_SECRET'), +], +``` + +- [ ] **Step 2: Verify .env already has the keys** + +Check `.env` contains: +``` +FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1 +FUEL_FINDER_CLIENT_ID=2MJ9mYAmZMApMF6fHT2rwd8pSL3JP9D3 +FUEL_FINDER_CLIENT_SECRET=oAZsWBagGD8YTn68BnWDjbV61SA7nOrHHlzKhH3s3oMj23kv0MsI8l25ILUbL5j6 +``` + +- [ ] **Step 3: Commit** + +```bash +git add config/services.php +git commit -m "config: add fuel_finder service credentials" +``` + +--- + +## Task 2: FuelType enum + +**Files:** +- Create: `app/Enums/FuelType.php` + +- [ ] **Step 1: Create the enum** + +```php +string('node_id', 64)->primary(); + $table->string('trading_name', 128); + $table->string('brand_name', 64)->nullable(); + $table->boolean('is_same_trading_and_brand')->default(false); + $table->boolean('is_supermarket')->default(false)->comment('Set by StationTaggingService'); + $table->boolean('is_motorway_service_station')->default(false); + $table->boolean('is_supermarket_service_station')->default(false); + $table->boolean('temporary_closure')->default(false); + $table->boolean('permanent_closure')->default(false); + $table->date('permanent_closure_date')->nullable(); + $table->string('public_phone_number', 20)->nullable(); + $table->string('address_line_1', 255)->nullable(); + $table->string('address_line_2', 255)->nullable(); + $table->string('city', 100)->nullable(); + $table->string('county', 100)->nullable(); + $table->string('country', 64)->nullable(); + $table->string('postcode', 10); + $table->decimal('lat', 10, 7); + $table->decimal('lng', 10, 7); + $table->json('amenities')->nullable(); + $table->json('opening_times')->nullable(); + $table->json('fuel_types')->nullable(); + $table->dateTime('last_seen_at'); + }); +} +``` + +- [ ] **Step 3: Implement station_prices_current migration** + +```php +public function up(): void +{ + Schema::create('station_prices_current', function (Blueprint $table): void { + $table->string('station_id', 64); + $table->string('fuel_type', 20); + $table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100, e.g. 15990 = 159.90p'); + $table->dateTime('price_effective_at')->comment('price_change_effective_timestamp from API'); + $table->dateTime('price_reported_at')->comment('price_last_updated from API'); + $table->dateTime('recorded_at')->comment('When this row was last upserted by us'); + + $table->primary(['station_id', 'fuel_type']); + $table->foreign('station_id')->references('node_id')->on('stations')->cascadeOnDelete(); + }); +} +``` + +- [ ] **Step 4: Implement station_prices migration (partitioned)** + +```php +public function up(): void +{ + Schema::create('station_prices', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('station_id', 64); + $table->string('fuel_type', 20); + $table->unsignedSmallInteger('price_pence')->comment('Price in pence × 100'); + $table->dateTime('price_effective_at'); + $table->dateTime('price_reported_at'); + $table->dateTime('recorded_at'); + + // Composite PK required for MySQL range partitioning + $table->primary(['id', 'price_effective_at']); + $table->index(['station_id', 'fuel_type', 'price_effective_at']); + $table->index('price_effective_at'); + }); + + // Add monthly partitions for 2026-2027, with MAXVALUE catch-all + DB::statement("ALTER TABLE station_prices + PARTITION BY RANGE (YEAR(price_effective_at) * 100 + MONTH(price_effective_at)) ( + PARTITION p202601 VALUES LESS THAN (202602), + PARTITION p202602 VALUES LESS THAN (202603), + PARTITION p202603 VALUES LESS THAN (202604), + PARTITION p202604 VALUES LESS THAN (202605), + PARTITION p202605 VALUES LESS THAN (202606), + PARTITION p202606 VALUES LESS THAN (202607), + PARTITION p202607 VALUES LESS THAN (202608), + PARTITION p202608 VALUES LESS THAN (202609), + PARTITION p202609 VALUES LESS THAN (202610), + PARTITION p202610 VALUES LESS THAN (202611), + PARTITION p202611 VALUES LESS THAN (202612), + PARTITION p202612 VALUES LESS THAN (202701), + PARTITION p202701 VALUES LESS THAN (202702), + PARTITION p202702 VALUES LESS THAN (202703), + PARTITION p202703 VALUES LESS THAN (202704), + PARTITION p202704 VALUES LESS THAN (202705), + PARTITION p202705 VALUES LESS THAN (202706), + PARTITION p202706 VALUES LESS THAN (202707), + PARTITION p202707 VALUES LESS THAN (202708), + PARTITION p202708 VALUES LESS THAN (202709), + PARTITION p202709 VALUES LESS THAN (202710), + PARTITION p202710 VALUES LESS THAN (202711), + PARTITION p202711 VALUES LESS THAN (202712), + PARTITION p202712 VALUES LESS THAN (202801), + PARTITION pFuture VALUES LESS THAN MAXVALUE + )"); +} + +public function down(): void +{ + Schema::dropIfExists('station_prices'); +} +``` + +- [ ] **Step 5: Implement station_prices_archive migration** + +```php +public function up(): void +{ + Schema::create('station_prices_archive', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('station_id', 64); + $table->string('fuel_type', 20); + $table->unsignedSmallInteger('price_pence'); + $table->dateTime('price_effective_at'); + $table->dateTime('price_reported_at'); + $table->dateTime('recorded_at'); + + $table->index(['station_id', 'fuel_type', 'price_effective_at']); + $table->index('price_effective_at'); + }); +} +``` + +- [ ] **Step 6: Run migrations** + +```bash +php artisan migrate +``` + +Expected: 4 new tables created with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add database/migrations/ +git commit -m "feat: add stations and station prices migrations" +``` + +--- + +## Task 4: Eloquent models + +**Files:** +- Create: `app/Models/Station.php` +- Create: `app/Models/StationPriceCurrent.php` +- Create: `app/Models/StationPrice.php` +- Create: `app/Models/StationPriceArchive.php` + +- [ ] **Step 1: Create Station model** + +```php + */ + use HasFactory; + + public $timestamps = false; + protected $primaryKey = 'node_id'; + public $incrementing = false; + protected $keyType = 'string'; + + protected function casts(): array + { + return [ + 'is_same_trading_and_brand' => 'boolean', + 'is_supermarket' => 'boolean', + 'is_motorway_service_station' => 'boolean', + 'is_supermarket_service_station' => 'boolean', + 'temporary_closure' => 'boolean', + 'permanent_closure' => 'boolean', + 'permanent_closure_date' => 'date', + 'amenities' => 'array', + 'opening_times' => 'array', + 'fuel_types' => 'array', + 'last_seen_at' => 'datetime', + ]; + } + + public function currentPrices(): HasMany + { + return $this->hasMany(StationPriceCurrent::class, 'station_id', 'node_id'); + } + + public function prices(): HasMany + { + return $this->hasMany(StationPrice::class, 'station_id', 'node_id'); + } +} +``` + +- [ ] **Step 2: Create StationPriceCurrent model** + +```php + */ + use HasFactory; + + public $timestamps = false; + protected $primaryKey = null; + public $incrementing = false; + + protected function casts(): array + { + return [ + 'fuel_type' => FuelType::class, + 'price_effective_at' => 'datetime', + 'price_reported_at' => 'datetime', + 'recorded_at' => 'datetime', + ]; + } + + public function station(): BelongsTo + { + return $this->belongsTo(Station::class, 'station_id', 'node_id'); + } + + /** Price in pence as a float, e.g. 159.90 */ + public function priceInPence(): float + { + return $this->price_pence / 100; + } +} +``` + +- [ ] **Step 3: Create StationPrice model** + +```php + */ + use HasFactory; + + public $timestamps = false; + + protected function casts(): array + { + return [ + 'fuel_type' => FuelType::class, + 'price_effective_at' => 'datetime', + 'price_reported_at' => 'datetime', + 'recorded_at' => 'datetime', + ]; + } + + public function station(): BelongsTo + { + return $this->belongsTo(Station::class, 'station_id', 'node_id'); + } +} +``` + +- [ ] **Step 4: Create StationPriceArchive model** + +```php + FuelType::class, + 'price_effective_at' => 'datetime', + 'price_reported_at' => 'datetime', + 'recorded_at' => 'datetime', + ]; + } + + public function station(): BelongsTo + { + return $this->belongsTo(Station::class, 'station_id', 'node_id'); + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add app/Models/ app/Enums/ +git commit -m "feat: add Station, StationPrice, StationPriceCurrent, StationPriceArchive models" +``` + +--- + +## Task 5: Factories + +**Files:** +- Create: `database/factories/StationFactory.php` +- Create: `database/factories/StationPriceCurrentFactory.php` +- Create: `database/factories/StationPriceFactory.php` + +- [ ] **Step 1: Create StationFactory** + +```php + */ +class StationFactory extends Factory +{ + public function definition(): array + { + $trading = $this->faker->company(); + + return [ + 'node_id' => hash('sha256', $this->faker->unique()->uuid()), + 'trading_name' => $trading, + 'brand_name' => $trading, + 'is_same_trading_and_brand' => true, + 'is_supermarket' => false, + 'is_motorway_service_station' => false, + 'is_supermarket_service_station' => false, + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'address_line_1' => $this->faker->streetAddress(), + 'address_line_2' => null, + 'city' => $this->faker->city(), + 'county' => null, + 'country' => 'England', + 'postcode' => strtoupper($this->faker->postcode()), + 'lat' => $this->faker->latitude(49.9, 60.9), + 'lng' => $this->faker->longitude(-8.2, 1.8), + 'amenities' => [], + 'opening_times' => null, + 'fuel_types' => ['E10', 'E5'], + 'last_seen_at' => now(), + ]; + } + + public function supermarket(): static + { + return $this->state([ + 'trading_name' => 'Tesco', + 'brand_name' => 'Tesco', + 'is_supermarket'=> true, + ]); + } +} +``` + +- [ ] **Step 2: Create StationPriceCurrentFactory** + +```php + */ +class StationPriceCurrentFactory extends Factory +{ + public function definition(): array + { + return [ + 'station_id' => Station::factory(), + 'fuel_type' => FuelType::E10, + 'price_pence' => $this->faker->numberBetween(12000, 18000), + 'price_effective_at' => now()->subHour(), + 'price_reported_at' => now()->subMinutes(30), + 'recorded_at' => now(), + ]; + } +} +``` + +- [ ] **Step 3: Create StationPriceFactory** + +```php + */ +class StationPriceFactory extends Factory +{ + public function definition(): array + { + return [ + 'station_id' => Station::factory(), + 'fuel_type' => FuelType::E10, + 'price_pence' => $this->faker->numberBetween(12000, 18000), + 'price_effective_at' => now()->subDays($this->faker->numberBetween(1, 30)), + 'price_reported_at' => now()->subDays($this->faker->numberBetween(1, 30)), + 'recorded_at' => now(), + ]; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add database/factories/ +git commit -m "feat: add Station, StationPrice, StationPriceCurrent factories" +``` + +--- + +## Task 6: StationTaggingService + +**Files:** +- Create: `app/Services/StationTaggingService.php` +- Create: `tests/Unit/Services/StationTaggingServiceTest.php` + +- [ ] **Step 1: Write failing tests** + +```php +service = new StationTaggingService(); +}); + +it('marks tesco station as supermarket and normalises brand', function (): void { + $station = Station::factory()->make(['trading_name' => 'TESCO EXTRA', 'is_supermarket' => false]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeTrue() + ->and($station->brand_name)->toBe('Tesco'); +}); + +it('marks asda station as supermarket', function (): void { + $station = Station::factory()->make(['trading_name' => 'Asda Petrol Station', 'is_supermarket' => false]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeTrue() + ->and($station->brand_name)->toBe('Asda'); +}); + +it('does not mark independent station as supermarket', function (): void { + $station = Station::factory()->make(['trading_name' => 'Village Garage', 'is_supermarket' => false]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeFalse(); +}); + +it('handles case insensitive matching', function (): void { + $station = Station::factory()->make(['trading_name' => 'morrisons petrol', 'is_supermarket' => false]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeTrue() + ->and($station->brand_name)->toBe('Morrisons'); +}); + +it('does not overwrite brand_name for non-supermarket stations', function (): void { + $station = Station::factory()->make([ + 'trading_name' => 'Shell Garage', + 'brand_name' => 'Shell', + 'is_supermarket' => false, + ]); + + $this->service->tag($station); + + expect($station->is_supermarket)->toBeFalse() + ->and($station->brand_name)->toBe('Shell'); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Unit/Services/StationTaggingServiceTest.php +``` + +Expected: FAIL — `StationTaggingService` not found. + +- [ ] **Step 3: Implement StationTaggingService** + +```php + brand keyword → normalised brand name */ + private const SUPERMARKET_BRANDS = [ + 'tesco' => 'Tesco', + 'asda' => 'Asda', + 'morrisons' => 'Morrisons', + 'sainsbury' => 'Sainsbury\'s', + 'aldi' => 'Aldi', + 'lidl' => 'Lidl', + 'costco' => 'Costco', + ]; + + public function tag(Station $station): void + { + $name = strtolower($station->trading_name); + + foreach (self::SUPERMARKET_BRANDS as $keyword => $normalisedBrand) { + if (str_contains($name, $keyword)) { + $station->is_supermarket = true; + $station->brand_name = $normalisedBrand; + return; + } + } + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +php artisan test tests/Unit/Services/StationTaggingServiceTest.php +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/StationTaggingService.php tests/Unit/Services/StationTaggingServiceTest.php +git commit -m "feat: add StationTaggingService with supermarket detection" +``` + +--- + +## Task 7: FuelPriceService — OAuth token + +**Files:** +- Create: `app/Services/FuelPriceService.php` +- Create: `tests/Unit/Services/FuelPriceServiceTest.php` + +- [ ] **Step 1: Write failing test** + +```php +service = new FuelPriceService(new StationTaggingService()); +}); + +it('fetches and caches an access token', function (): void { + Http::fake([ + '*/oauth/generate_access_token' => Http::response([ + 'data' => [ + 'access_token' => 'test-token-abc', + 'expires_in' => 3600, + ], + ]), + ]); + + $token = $this->service->getAccessToken(); + + expect($token)->toBe('test-token-abc'); + expect(Cache::get('fuel_finder_access_token'))->toBe('test-token-abc'); +}); + +it('returns cached token without hitting API', function (): void { + Cache::put('fuel_finder_access_token', 'cached-token', 3540); + + Http::fake(); // no calls expected + + $token = $this->service->getAccessToken(); + + expect($token)->toBe('cached-token'); + Http::assertNothingSent(); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php +``` + +Expected: FAIL — `FuelPriceService` not found. + +- [ ] **Step 3: Implement FuelPriceService with token method** + +```php +post(config('services.fuel_finder.base_url').'/oauth/generate_access_token', [ + 'client_id' => config('services.fuel_finder.client_id'), + 'client_secret' => config('services.fuel_finder.client_secret'), + ]); + + return $response->json('data.access_token'); + }); + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php +git commit -m "feat: FuelPriceService with OAuth token caching" +``` + +--- + +## Task 8: FuelPriceService — station upsert + +**Files:** +- Modify: `app/Services/FuelPriceService.php` +- Modify: `tests/Unit/Services/FuelPriceServiceTest.php` + +- [ ] **Step 1: Write failing test** + +Add to `tests/Unit/Services/FuelPriceServiceTest.php`: + +```php +use App\Models\Station; +use Illuminate\Foundation\Testing\RefreshDatabase; + +uses(RefreshDatabase::class); + +it('upserts stations from API batch response', function (): void { + $apiStations = [ + [ + 'node_id' => 'abc123', + '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', 'E5'], + ], + ]; + + $this->service->upsertStations($apiStations); + + $station = Station::find('abc123'); + expect($station)->not->toBeNull() + ->and($station->trading_name)->toBe('Village Garage') + ->and($station->postcode)->toBe('SW1A 1AA') + ->and($station->lat)->toBe(51.5) + ->and($station->is_supermarket)->toBeFalse(); +}); + +it('tags supermarket stations during upsert', function (): void { + $apiStations = [[ + 'node_id' => 'tesco1', + 'trading_name' => 'TESCO', + 'brand_name' => 'TESCO', + 'is_same_trading_and_brand_name' => true, + 'is_motorway_service_station' => false, + 'is_supermarket_service_station' => true, + 'temporary_closure' => false, + 'permanent_closure' => false, + 'permanent_closure_date' => null, + 'public_phone_number' => null, + 'location' => [ + 'address_line_1' => '1 Tesco Way', + 'address_line_2' => null, + 'city' => 'Bristol', + 'county' => null, + 'country' => 'England', + 'postcode' => 'BS1 1AA', + 'latitude' => 51.45, + 'longitude' => -2.6, + ], + 'amenities' => [], + 'opening_times'=> null, + 'fuel_types' => ['E10'], + ]]; + + $this->service->upsertStations($apiStations); + + expect(Station::find('tesco1')->is_supermarket)->toBeTrue(); +}); +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="upserts stations" +``` + +Expected: FAIL — `upsertStations` not found. + +- [ ] **Step 3: Implement upsertStations** + +Add to `FuelPriceService`: + +```php +use App\Models\Station; +use Illuminate\Support\Carbon; + +/** @param array> $apiStations */ +public function upsertStations(array $apiStations): void +{ + $now = now(); + $rows = []; + + foreach ($apiStations as $data) { + $station = new Station([ + 'node_id' => $data['node_id'], + 'trading_name' => $data['trading_name'], + 'brand_name' => $data['brand_name'] ?? null, + 'is_same_trading_and_brand' => $data['is_same_trading_and_brand_name'] ?? false, + 'is_supermarket' => false, + 'is_motorway_service_station' => $data['is_motorway_service_station'] ?? false, + 'is_supermarket_service_station' => $data['is_supermarket_service_station'] ?? false, + 'temporary_closure' => $data['temporary_closure'] ?? false, + 'permanent_closure' => $data['permanent_closure'] ?? false, + 'permanent_closure_date' => $data['permanent_closure_date'] ?? null, + 'public_phone_number' => $data['public_phone_number'] ?? null, + 'address_line_1' => $data['location']['address_line_1'] ?? null, + 'address_line_2' => $data['location']['address_line_2'] ?? null, + 'city' => $data['location']['city'] ?? null, + 'county' => $data['location']['county'] ?? null, + 'country' => $data['location']['country'] ?? null, + 'postcode' => $data['location']['postcode'], + 'lat' => $data['location']['latitude'], + 'lng' => $data['location']['longitude'], + 'amenities' => $data['amenities'] ?? [], + 'opening_times' => $data['opening_times'] ?? null, + 'fuel_types' => $data['fuel_types'] ?? [], + 'last_seen_at' => $now, + ]); + + $this->taggingService->tag($station); + $rows[] = $station->getAttributes(); + } + + Station::upsert( + rows: $rows, + uniqueBy: ['node_id'], + update: array_keys($rows[0] ?? []), + ); +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php +``` + +Expected: 4 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php +git commit -m "feat: FuelPriceService station upsert with tagging" +``` + +--- + +## Task 9: FuelPriceService — price ingestion + +**Files:** +- Modify: `app/Services/FuelPriceService.php` +- Modify: `tests/Unit/Services/FuelPriceServiceTest.php` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/Unit/Services/FuelPriceServiceTest.php`: + +```php +use App\Models\StationPrice; +use App\Models\StationPriceCurrent; + +it('inserts new price row and upserts current when price changes', function (): void { + $station = Station::factory()->create(['node_id' => 'st001']); + + // Existing current price + StationPriceCurrent::create([ + 'station_id' => 'st001', + 'fuel_type' => 'e10', + 'price_pence' => 14900, // 149.00p + 'price_effective_at' => now()->subDay(), + 'price_reported_at' => now()->subDay(), + 'recorded_at' => now()->subDay(), + ]); + + $apiData = [[ + 'node_id' => 'st001', + 'fuel_prices' => [[ + 'fuel_type' => 'E10', + 'price' => 151.9, // changed price + 'price_last_updated' => '2026-04-03T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z', + ]], + ]]; + + $this->service->processPrices($apiData); + + expect(StationPrice::where('station_id', 'st001')->count())->toBe(1); + expect(StationPriceCurrent::where('station_id', 'st001')->where('fuel_type', 'e10')->first()->price_pence) + ->toBe(15190); +}); + +it('skips insert when price has not changed', function (): void { + $station = Station::factory()->create(['node_id' => 'st002']); + + StationPriceCurrent::create([ + 'station_id' => 'st002', + 'fuel_type' => 'e10', + 'price_pence' => 15190, // same as incoming + 'price_effective_at' => now()->subDay(), + 'price_reported_at' => now()->subDay(), + 'recorded_at' => now()->subDay(), + ]); + + $apiData = [[ + 'node_id' => 'st002', + 'fuel_prices' => [[ + 'fuel_type' => 'E10', + 'price' => 151.9, + 'price_last_updated' => '2026-04-03T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z', + ]], + ]]; + + $this->service->processPrices($apiData); + + expect(StationPrice::where('station_id', 'st002')->count())->toBe(0); +}); + +it('inserts price for station with no existing current price', function (): void { + $station = Station::factory()->create(['node_id' => 'st003']); + + $apiData = [[ + 'node_id' => 'st003', + 'fuel_prices' => [[ + 'fuel_type' => 'E5', + 'price' => 159.9, + 'price_last_updated' => '2026-04-03T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z', + ]], + ]]; + + $this->service->processPrices($apiData); + + expect(StationPrice::where('station_id', 'st003')->count())->toBe(1); + expect(StationPriceCurrent::where('station_id', 'st003')->where('fuel_type', 'e5')->first()->price_pence) + ->toBe(15990); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="price" +``` + +Expected: FAIL — `processPrices` not found. + +- [ ] **Step 3: Implement processPrices** + +Add to `FuelPriceService`: + +```php +use App\Enums\FuelType; +use App\Models\StationPrice; +use App\Models\StationPriceCurrent; + +/** @param array> $apiStations */ +public function processPrices(array $apiStations): void +{ + $now = now(); + + // Load current prices keyed by "station_id:fuel_type" for fast dedup lookup + $stationIds = array_column($apiStations, 'node_id'); + $currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds) + ->get() + ->keyBy(fn ($row) => $row->station_id.':'.$row->fuel_type->value); + + $newHistoryRows = []; + $upsertCurrentRows = []; + + foreach ($apiStations as $station) { + foreach ($station['fuel_prices'] ?? [] as $fp) { + $fuelType = FuelType::fromApiValue($fp['fuel_type']); + $pricePence = (int) round($fp['price'] * 100); + $key = $station['node_id'].':'.$fuelType->value; + + // Skip if price unchanged + if (isset($currentPrices[$key]) && $currentPrices[$key]->price_pence === $pricePence) { + continue; + } + + $row = [ + 'station_id' => $station['node_id'], + 'fuel_type' => $fuelType->value, + 'price_pence' => $pricePence, + 'price_effective_at' => Carbon::parse($fp['price_change_effective_timestamp']), + 'price_reported_at' => Carbon::parse($fp['price_last_updated']), + 'recorded_at' => $now, + ]; + + $newHistoryRows[] = $row; + $upsertCurrentRows[] = $row; + } + } + + if ($newHistoryRows !== []) { + StationPrice::insert($newHistoryRows); + } + + if ($upsertCurrentRows !== []) { + StationPriceCurrent::upsert( + rows: $upsertCurrentRows, + uniqueBy: ['station_id', 'fuel_type'], + update: ['price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'], + ); + } +} +``` + +- [ ] **Step 4: Run all service tests** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php +``` + +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Services/FuelPriceService.php tests/Unit/Services/FuelPriceServiceTest.php +git commit -m "feat: FuelPriceService price ingestion with deduplication" +``` + +--- + +## Task 10: FuelPriceService — full and incremental poll methods + +**Files:** +- Modify: `app/Services/FuelPriceService.php` +- Modify: `tests/Unit/Services/FuelPriceServiceTest.php` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/Unit/Services/FuelPriceServiceTest.php`: + +```php +use App\Events\PricesUpdatedEvent; +use Illuminate\Support\Facades\Event; + +it('incremental poll processes prices from no-batch endpoint', function (): void { + Event::fake(); + Station::factory()->create(['node_id' => 'st010']); + + Http::fake([ + '*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]), + '*/pfs/fuel-prices' => Http::response([[ + 'node_id' => 'st010', + 'fuel_prices' => [[ + 'fuel_type' => 'E10', + 'price' => 149.9, + 'price_last_updated' => '2026-04-03T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z', + ]], + ]]), + ]); + + $this->service->pollIncremental(); + + expect(StationPrice::count())->toBe(1); + Event::assertDispatched(PricesUpdatedEvent::class); +}); + +it('full poll iterates all batches and stops when fewer than 500 records returned', function (): void { + Event::fake(); + Station::factory()->create(['node_id' => 'full001']); + + $pfsStation = [[ + 'node_id' => 'full001', + 'trading_name' => 'Test Garage', + 'brand_name' => 'Test 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 Road', + 'address_line_2' => null, + 'city' => 'London', + 'county' => null, + 'country' => 'England', + 'postcode' => 'E1 1AA', + 'latitude' => 51.5, + 'longitude' => -0.1, + ], + 'amenities' => [], + 'opening_times'=> null, + 'fuel_types' => ['E10'], + ]]; + + Http::fake([ + '*/oauth/generate_access_token' => Http::response(['data' => ['access_token' => 'tok', 'expires_in' => 3600]]), + '*/pfs?batch-number=1' => Http::response($pfsStation), // < 500 = last batch + '*/pfs/fuel-prices?batch-number=1' => Http::response([[ + 'node_id' => 'full001', + 'fuel_prices' => [[ + 'fuel_type' => 'E10', + 'price' => 149.9, + 'price_last_updated' => '2026-04-03T12:00:00.000Z', + 'price_change_effective_timestamp' => '2026-04-03T11:00:00.000Z', + ]], + ]]), + ]); + + $this->service->pollFull(); + + expect(Station::where('node_id', 'full001')->exists())->toBeTrue(); + expect(StationPrice::count())->toBe(1); + Event::assertDispatched(PricesUpdatedEvent::class); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="poll" +``` + +Expected: FAIL — `PricesUpdatedEvent` and poll methods not found. + +- [ ] **Step 3: Create PricesUpdatedEvent** + +```php +getAccessToken(); + $response = Http::timeout(30) + ->withToken($token) + ->get(config('services.fuel_finder.base_url').'/pfs/fuel-prices'); + + $stations = $response->json() ?? []; + $this->processPrices($stations); + + PricesUpdatedEvent::dispatch( + stationsProcessed: count($stations), + priceChanges: 0, + isFullRefresh: false, + ); +} + +public function pollFull(): void +{ + $token = $this->getAccessToken(); + $base = config('services.fuel_finder.base_url'); + $batch = 1; + + // Full station metadata refresh + do { + $stations = Http::timeout(30) + ->withToken($token) + ->get("{$base}/pfs", ['batch-number' => $batch]) + ->json() ?? []; + + if ($stations !== []) { + $this->upsertStations($stations); + } + + $batch++; + } while (count($stations) >= 500); + + // Full price refresh + $batch = 1; + $totalPriceStations = 0; + + do { + $priceStations = Http::timeout(30) + ->withToken($token) + ->get("{$base}/pfs/fuel-prices", ['batch-number' => $batch]) + ->json() ?? []; + + if ($priceStations !== []) { + $this->processPrices($priceStations); + $totalPriceStations += count($priceStations); + } + + $batch++; + } while (count($priceStations) >= 500); + + PricesUpdatedEvent::dispatch( + stationsProcessed: $totalPriceStations, + priceChanges: 0, + isFullRefresh: true, + ); +} +``` + +- [ ] **Step 5: Run all service tests** + +```bash +php artisan test tests/Unit/Services/FuelPriceServiceTest.php +``` + +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add app/Services/FuelPriceService.php app/Events/PricesUpdatedEvent.php tests/Unit/Services/FuelPriceServiceTest.php +git commit -m "feat: FuelPriceService incremental and full poll methods" +``` + +--- + +## Task 11: PollFuelPricesCommand + +**Files:** +- Create: `app/Console/Commands/PollFuelPricesCommand.php` +- Create: `tests/Feature/Commands/PollFuelPricesCommandTest.php` + +- [ ] **Step 1: Write failing tests** + +```php +expects('pollIncremental')->once(); + $service->shouldNotReceive('pollFull'); + app()->instance(FuelPriceService::class, $service); + + $this->artisan('fuel:poll') + ->assertSuccessful(); +}); + +it('runs full poll with --full flag', function (): void { + $service = Mockery::mock(FuelPriceService::class); + $service->expects('pollFull')->once(); + $service->shouldNotReceive('pollIncremental'); + app()->instance(FuelPriceService::class, $service); + + $this->artisan('fuel:poll --full') + ->assertSuccessful(); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php +``` + +Expected: FAIL — command not found. + +- [ ] **Step 3: Implement PollFuelPricesCommand** + +```php +option('full')) { + $this->info('Running full refresh...'); + $service->pollFull(); + $this->info('Full refresh complete.'); + } else { + $this->info('Running incremental poll...'); + $service->pollIncremental(); + $this->info('Incremental poll complete.'); + } + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Console/Commands/PollFuelPricesCommand.php tests/Feature/Commands/PollFuelPricesCommandTest.php +git commit -m "feat: add fuel:poll artisan command" +``` + +--- + +## Task 12: ArchiveOldPricesCommand + +**Files:** +- Create: `app/Console/Commands/ArchiveOldPricesCommand.php` +- Create: `tests/Feature/Commands/ArchiveOldPricesCommandTest.php` + +- [ ] **Step 1: Write failing tests** + +```php +create(); + + // Old price — should be archived + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_effective_at' => now()->subMonths(13), + 'price_reported_at' => now()->subMonths(13), + 'recorded_at' => now()->subMonths(13), + ]); + + // Recent price — should stay + StationPrice::factory()->create([ + 'station_id' => $station->node_id, + 'price_effective_at' => now()->subMonths(6), + 'price_reported_at' => now()->subMonths(6), + 'recorded_at' => now()->subMonths(6), + ]); + + $this->artisan('fuel:archive')->assertSuccessful(); + + expect(StationPrice::count())->toBe(1); + expect(StationPriceArchive::count())->toBe(1); +}); + +it('does nothing when no old prices exist', function (): void { + Station::factory()->create(); + + $this->artisan('fuel:archive') + ->expectsOutput('No prices to archive.') + ->assertSuccessful(); +}); +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php +``` + +Expected: FAIL — command not found. + +- [ ] **Step 3: Implement ArchiveOldPricesCommand** + +```php +subMonths(12); + + $count = StationPrice::where('price_effective_at', '<', $cutoff)->count(); + + if ($count === 0) { + $this->info('No prices to archive.'); + return self::SUCCESS; + } + + $this->info("Archiving {$count} price records older than {$cutoff->toDateString()}..."); + + // Move in chunks to avoid memory issues + StationPrice::where('price_effective_at', '<', $cutoff) + ->chunkById(1000, function ($prices): void { + $rows = $prices->map->getAttributes()->toArray(); + DB::transaction(function () use ($rows, $prices): void { + StationPriceArchive::insert($rows); + StationPrice::whereIn('id', $prices->pluck('id'))->delete(); + }); + }); + + $this->info('Archive complete.'); + + return self::SUCCESS; + } +} +``` + +- [ ] **Step 4: Run tests to confirm they pass** + +```bash +php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php +``` + +Expected: 2 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add app/Console/Commands/ArchiveOldPricesCommand.php tests/Feature/Commands/ArchiveOldPricesCommandTest.php +git commit -m "feat: add fuel:archive command for price rotation" +``` + +--- + +## Task 13: Register scheduler + +**Files:** +- Modify: `bootstrap/app.php` + +- [ ] **Step 1: Register scheduled commands** + +In `bootstrap/app.php`, add `->withSchedule(...)` after `->withMiddleware(...)`: + +```php +use App\Console\Commands\ArchiveOldPricesCommand; +use App\Console\Commands\PollFuelPricesCommand; +use Illuminate\Console\Scheduling\Schedule; + +return Application::configure(basePath: dirname(__DIR__)) + ->withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withSchedule(function (Schedule $schedule): void { + $schedule->command(PollFuelPricesCommand::class) + ->everyFifteenMinutes() + ->withoutOverlapping() + ->runInBackground(); + + $schedule->command(PollFuelPricesCommand::class, ['--full']) + ->dailyAt('03:00') + ->withoutOverlapping(); + + $schedule->command(ArchiveOldPricesCommand::class) + ->monthlyOn(1, '04:00'); + }) + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); +``` + +- [ ] **Step 2: Verify scheduler shows commands** + +```bash +php artisan schedule:list +``` + +Expected: `fuel:poll` (every 15 min), `fuel:poll --full` (daily 3am), `fuel:archive` (monthly) listed. + +- [ ] **Step 3: Run full test suite** + +```bash +php artisan test +``` + +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add bootstrap/app.php +git commit -m "feat: register fuel polling and archive commands in scheduler" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- ✅ OAuth token fetch + cache (`fuel_finder_access_token`, TTL 3540s) +- ✅ Token refresh endpoint noted in api-data.md (used in `getAccessToken` on cache miss) +- ✅ Station upsert from `/pfs` with all API fields +- ✅ `StationTaggingService` brand detection +- ✅ Price deduplication via `station_prices_current` +- ✅ Both timestamps stored (`price_effective_at`, `price_reported_at`) +- ✅ `station_prices` partitioned monthly, composite PK +- ✅ `station_prices_archive` identical schema, no partition +- ✅ Incremental poll (every 15 min) + full poll (daily) +- ✅ `PricesUpdatedEvent` dispatched after each poll +- ✅ `fuel:archive` command with chunked move +- ✅ Scheduler registration with `withoutOverlapping()` + +**No placeholders found.** + +**Type consistency confirmed** — `FuelType::fromApiValue()` used consistently in `processPrices`, `fuel_type` stored as enum value (string) in DB rows.