Files
fuel-price/.claude/rules/api-data.md
Ovidiu U 5acb99c9e3
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Remove obsolete Livewire fuel search components and consolidate pricing tiers
- Delete unused Livewire Search test and fuel type select Blade component
- Move subscription webhook listener from EventServiceProvider to AppServiceProvider
- Add FUEL_TYPES global config to app layout for client-side use
- Add Billable trait to User model and include email_verified_at in fillable
- Implement monthly/annual cadence toggle with pricing display and smart CTA routing on homepage
- Update VerifyApiKeyMiddlewareTest to use e10 instead of petrol
- Refactor PollFuelPrices to auto-refresh stale stations based on last_seen_at
- Add incremental polling with cached timestamp and effective-start-timestamp param to FuelPriceService
- Normalize amenities/fuel_types from API objects to flat arrays, skip stations missing required fields
- Log response body on API failures in ApiLogger
- Default homepage sort to 'reliable' instead of 'price'
2026-04-20 14:12:15 +01:00

8.4 KiB
Raw Blame History

External API & Data Sources

UK Fuel Finder API (gov.uk) — PRIMARY SOURCE

  • Base URL: https://www.fuel-finder.service.gov.uk/api/v1/
  • Returns: all UK station prices + station metadata (~14,500 stations)
  • Update frequency: stations report within 30 minutes of price change
  • Our polling interval: every 30 minutes via scheduler (incremental using effective-start-timestamp), station metadata auto-refreshed once per day on the first poll after midnight

Authentication

OAuth 2.0 using JSON body POST (not form-encoded). Credentials in .env as FUEL_FINDER_CLIENT_ID / FUEL_FINDER_CLIENT_SECRET.

Get token:

POST /api/v1/oauth/generate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}

Response: {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "eyJ..."}

Refresh token:

POST /api/v1/oauth/regenerate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}

Token caching strategy (FuelPriceService):

  • Cache the access_token with TTL = expires_in - 60 seconds (3540s)
  • Cache key: fuel_finder_access_token
  • On cache miss: call generate_access_token, store result, return token
  • Use the refresh_token to regenerate before expiry if needed
  • Include token in every API request: Authorization: Bearer {token}

Endpoints

  • GET /api/v1/pfs/fuel-prices?batch-number={n} — all station prices (500 stations per batch)
  • GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS — incremental, only prices changed since timestamp
  • GET /api/v1/pfs?batch-number={n} — all station metadata (500 per batch)
  • GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS — incremental station metadata

Fuel prices response fields (array of stations):

  • node_id, public_phone_number, trading_name — station identifiers
  • fuel_prices[] — array of {fuel_type, price, price_last_updated, price_change_effective_timestamp}
  • Fuel types (API casing): E5, E10, B7_Standard, B7_Premium, B10, HVO — lowercased on ingest via FuelType::fromApiValue()
  • Price is a float (e.g. 159.9 = 159.9p) — multiply × 100 and store as integer pence

Station metadata response fields (array of stations):

  • node_id, trading_name, brand_name, is_same_trading_and_brand_name, public_phone_number
  • is_supermarket_service_station, is_motorway_service_station
  • temporary_closure, permanent_closure, permanent_closure_date
  • location{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}
  • amenitiesOBJECT with boolean flags: {adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}. Normalised at ingest to a flat array of enabled keys.
  • fuel_typesOBJECT with boolean flags: {E10, E5, B7_Standard, B7_Premium, B10, HVO}. Normalised at ingest to a flat array of enabled keys.
  • opening_timesusual_days.{monday..sunday}.{open, close, is_24_hours} + bank_holidays.type.{open_time, close_time, is_24_hours}. Stored as raw JSON, not used in scoring.

Required-field validation

Stations missing any of node_id, trading_name, location.postcode, location.latitude, location.longitude are dropped at ingest with a warning. Price rows missing any of fuel_type, price, price_last_updated, price_change_effective_timestamp are skipped silently.

Incremental polling (FuelPriceService::pollPrices)

On each successful poll the wall-clock start time is cached under fuel_finder_last_price_poll_at (forever). The next poll sends this as effective-start-timestamp. Cold start (cache miss) performs a full fetch.

FK safety

Price batches are filtered against the stations table before insert — any station not yet in stations is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up.

FuelPriceService responsibilities

  1. Fetch OAuth token (cache it)
  2. GET all station prices
  3. Upsert stations table with metadata
  4. Insert new rows into station_prices only when price has changed for that station+fuel combo
  5. Call StationTaggingService to set is_supermarket and brand
  6. Dispatch PricesUpdatedEvent for downstream processing

Deduplication

Only insert a new station_prices row if price differs from the most recent stored price for that (station_id, fuel_type) combination. Avoids row explosion on unchanged prices.

Credentials in .env

FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk

Postcodes.io — location resolution

  • Free, no API key required
  • Handled by PostcodeService::resolve(string $query): ?LocationResult
  • Returns LocationResult DTO with query, displayName, lat, lng
  • Results cached for 30 days — cache key postcode:{normalised_input}
  • Failed lookups are NOT cached — retried on next request
  • Input is auto-detected:
Input type Example Endpoint
Full postcode SW1A 1AA GET /postcodes/{postcode}
Outcode (district) PE7 GET /outcodes/{outcode}
Place / city name Manchester GET /places?q={query}&limit=1

Anonymous search flow: user types a postcode/city → PostcodeService::resolve() → lat/lng stored in a JSON cookie (30 days) alongside the query string. On return visits, cookie lat/lng is used directly — postcodes.io is only called when the search term changes.

Registered users: postcode resolved once on registration, lat/lng stored on users table — not re-resolved unless postcode changes.

FRED API (St. Louis Fed) — Brent crude prices

  • Series: DCOILBRENTEU (daily Brent spot price, USD/barrel)
  • Endpoint: GET https://api.stlouisfed.org/fred/series/observations
  • Params: series_id=DCOILBRENTEU, sort_order=desc, limit=30, file_type=json
  • Free API key required — stored as FRED_API_KEY in .env
  • Handled by OilPriceService::fetchBrentPrices()
  • Fetched daily at 7am via oil:predict --fetch scheduler command
  • FRED uses "." as a placeholder for non-trading days (weekends/holidays) — filtered out before insert
  • Stored in brent_prices table, upserted on date primary key

Anthropic API — oil price direction prediction

  • Endpoint: POST https://api.anthropic.com/v1/messages
  • Model: claude-haiku-4-5-20251001 (configurable via ANTHROPIC_MODEL in .env)
  • Key stored as ANTHROPIC_API_KEY in .env
  • Handled by OilPriceService::generateLlmPrediction()
  • Called once daily after FRED fetch — sends last 30 days of Brent prices + pre-computed EWMA context
  • Response must be JSON: {"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "..."}
  • Model sometimes wraps JSON in markdown code fences — these are stripped before json_decode
  • Confidence is capped at 85 regardless of what the model returns
  • On any failure (API error, malformed JSON, invalid direction) → falls back to EWMA silently
  • Result stored in price_predictions table with source = 'llm'

EWMA fallback (OilPriceService::generateEwmaPrediction()):

  • Compares 3-day EWMA vs 7-day EWMA on chronological Brent price data
  • Threshold: ±1.5% change → rising/falling; below → flat
  • Confidence capped at 65 (simpler model)
  • Used when: no ANTHROPIC_API_KEY set, or LLM call fails
  • Result stored in price_predictions table with source = 'ewma'

OneSignal — push notifications

  • REST API: https://oapi.onesignal.com/notifications
  • App ID + REST API key stored in .env as ONESIGNAL_APP_ID, ONESIGNAL_API_KEY
  • Target by player_id (stored in users.push_token)
  • No official Laravel package needed — use Laravel HTTP client (Http::post(...))
  • Free plan: 10,000 subscribers — sufficient for v1

Vonage — WhatsApp + SMS

  • Package: vonage/client-core via Composer
  • Credentials: VONAGE_KEY, VONAGE_SECRET, VONAGE_WHATSAPP_FROM in .env
  • WhatsApp: Messages API, utility template category (pre-approved)
  • SMS: SMS API, alphanumeric sender ID "FuelAlert"
  • All Vonage calls go through NotificationDispatchService — never call Vonage directly from components

HTTP client

Use Laravel's built-in Http facade for all external API calls. Always set a timeout: Http::timeout(10)->get(...). Wrap in try/catch — log failures, never let a failed API call crash the scheduler.