# 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.