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

1687 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
<?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**
```bash
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**
```bash
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:
```php
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**
```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
<?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
<?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
<?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
<?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**
```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
<?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
<?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
<?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**
```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
<?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**
```bash
php artisan test tests/Unit/Services/StationTaggingServiceTest.php
```
Expected: FAIL — `StationTaggingService` not found.
- [ ] **Step 3: Implement StationTaggingService**
```php
<?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**
```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
<?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**
```bash
php artisan test tests/Unit/Services/FuelPriceServiceTest.php
```
Expected: FAIL — `FuelPriceService` not found.
- [ ] **Step 3: Implement FuelPriceService with token method**
```php
<?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**
```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<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**
```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<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**
```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
<?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`:
```php
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**
```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
<?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**
```bash
php artisan test tests/Feature/Commands/PollFuelPricesCommandTest.php
```
Expected: FAIL — command not found.
- [ ] **Step 3: Implement PollFuelPricesCommand**
```php
<?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**
```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
<?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**
```bash
php artisan test tests/Feature/Commands/ArchiveOldPricesCommandTest.php
```
Expected: FAIL — command not found.
- [ ] **Step 3: Implement ArchiveOldPricesCommand**
```php
<?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**
```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.