Files
fuel-price/docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md
2026-04-03 18:21:51 +01:00

51 KiB
Raw Blame History

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 ]:

'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
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

namespace App\Enums;

enum FuelType: string
{
    case E10        = 'e10';
    case E5         = 'e5';
    case B7Standard = 'b7_standard';
    case B7Premium  = 'b7_premium';
    case B10        = 'b10';
    case Hvo        = 'hvo';

    public static function fromApiValue(string $value): self
    {
        return self::from(strtolower($value));
    }
}
  • Step 2: Commit
git add app/Enums/FuelType.php
git commit -m "feat: add FuelType backed enum"

Task 3: Migrations

Files:

  • Create: 4 migration files

  • Step 1: Generate migration stubs

php artisan make:migration create_stations_table
php artisan make:migration create_station_prices_current_table
php artisan make:migration create_station_prices_table
php artisan make:migration create_station_prices_archive_table
  • Step 2: Implement stations migration

Replace the generated up() method:

public function up(): void
{
    Schema::create('stations', function (Blueprint $table): void {
        $table->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
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)
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
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
php artisan migrate

Expected: 4 new tables created with no errors.

  • Step 7: Commit
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

namespace App\Models;

use App\Enums\FuelType;
use Database\Factories\StationFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

#[Fillable([
    'node_id', 'trading_name', 'brand_name', 'is_same_trading_and_brand',
    'is_supermarket', 'is_motorway_service_station', 'is_supermarket_service_station',
    'temporary_closure', 'permanent_closure', 'permanent_closure_date',
    'public_phone_number', 'address_line_1', 'address_line_2', 'city',
    'county', 'country', 'postcode', 'lat', 'lng',
    'amenities', 'opening_times', 'fuel_types', 'last_seen_at',
])]
class Station extends Model
{
    /** @use HasFactory<StationFactory> */
    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

namespace App\Models;

use App\Enums\FuelType;
use Database\Factories\StationPriceCurrentFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
class StationPriceCurrent extends Model
{
    /** @use HasFactory<StationPriceCurrentFactory> */
    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

namespace App\Models;

use App\Enums\FuelType;
use Database\Factories\StationPriceFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
class StationPrice extends Model
{
    /** @use HasFactory<StationPriceFactory> */
    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

namespace App\Models;

use App\Enums\FuelType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;

#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
class StationPriceArchive extends Model
{
    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 5: Commit
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

namespace Database\Factories;

use App\Models\Station;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<Station> */
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

namespace Database\Factories;

use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPriceCurrent;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<StationPriceCurrent> */
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

namespace Database\Factories;

use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPrice;
use Illuminate\Database\Eloquent\Factories\Factory;

/** @extends Factory<StationPrice> */
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
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

use App\Models\Station;
use App\Services\StationTaggingService;

beforeEach(function (): void {
    $this->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
php artisan test tests/Unit/Services/StationTaggingServiceTest.php

Expected: FAIL — StationTaggingService not found.

  • Step 3: Implement StationTaggingService
<?php

namespace App\Services;

use App\Models\Station;

class StationTaggingService
{
    /** @var array<string, string> 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
php artisan test tests/Unit/Services/StationTaggingServiceTest.php

Expected: 5 tests pass.

  • Step 5: Commit
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

use App\Services\FuelPriceService;
use App\Services\StationTaggingService;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

beforeEach(function (): void {
    $this->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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php

Expected: FAIL — FuelPriceService not found.

  • Step 3: Implement FuelPriceService with token method
<?php

namespace App\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;

class FuelPriceService
{
    private const TOKEN_CACHE_KEY = 'fuel_finder_access_token';

    public function __construct(
        private readonly StationTaggingService $taggingService,
    ) {}

    public function getAccessToken(): string
    {
        return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
            $response = Http::timeout(10)
                ->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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php

Expected: 2 tests pass.

  • Step 5: Commit
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:

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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="upserts stations"

Expected: FAIL — upsertStations not found.

  • Step 3: Implement upsertStations

Add to FuelPriceService:

use App\Models\Station;
use Illuminate\Support\Carbon;

/** @param array<int, array<string, mixed>> $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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php

Expected: 4 tests pass.

  • Step 5: Commit
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:

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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="price"

Expected: FAIL — processPrices not found.

  • Step 3: Implement processPrices

Add to FuelPriceService:

use App\Enums\FuelType;
use App\Models\StationPrice;
use App\Models\StationPriceCurrent;

/** @param array<int, array<string, mixed>> $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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php

Expected: all tests pass.

  • Step 5: Commit
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:

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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php --filter="poll"

Expected: FAIL — PricesUpdatedEvent and poll methods not found.

  • Step 3: Create PricesUpdatedEvent
<?php

namespace App\Events;

class PricesUpdatedEvent
{
    public function __construct(
        public readonly int $stationsProcessed,
        public readonly int $priceChanges,
        public readonly bool $isFullRefresh,
    ) {}
}
  • Step 4: Implement pollIncremental and pollFull

Add to FuelPriceService:

use App\Events\PricesUpdatedEvent;

public function pollIncremental(): void
{
    $token    = $this->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
php artisan test tests/Unit/Services/FuelPriceServiceTest.php

Expected: all tests pass.

  • Step 6: Commit
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

use App\Services\FuelPriceService;
use App\Services\StationTaggingService;

it('runs incremental poll by default', function (): void {
    $service = Mockery::mock(FuelPriceService::class);
    $service->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
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php

Expected: FAIL — command not found.

  • Step 3: Implement PollFuelPricesCommand
<?php

namespace App\Console\Commands;

use App\Services\FuelPriceService;
use Illuminate\Console\Command;

class PollFuelPricesCommand extends Command
{
    protected $signature   = 'fuel:poll {--full : Run a full refresh of all stations and prices}';
    protected $description = 'Poll the UK Fuel Finder API for price updates';

    public function handle(FuelPriceService $service): int
    {
        if ($this->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
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php

Expected: 2 tests pass.

  • Step 5: Commit
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

use App\Models\Station;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('moves prices older than 12 months to archive', function (): void {
    $station = Station::factory()->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
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php

Expected: FAIL — command not found.

  • Step 3: Implement ArchiveOldPricesCommand
<?php

namespace App\Console\Commands;

use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;

class ArchiveOldPricesCommand extends Command
{
    protected $signature   = 'fuel:archive';
    protected $description = 'Move station price history older than 12 months to the archive table';

    public function handle(): int
    {
        $cutoff = Carbon::now()->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
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php

Expected: 2 tests pass.

  • Step 5: Commit
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(...):

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
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
php artisan test

Expected: all tests pass.

  • Step 4: Commit
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 confirmedFuelType::fromApiValue() used consistently in processPrices, fuel_type stored as enum value (string) in DB rows.