- 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'
157 lines
8.4 KiB
Markdown
157 lines
8.4 KiB
Markdown
# 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}`
|
||
- `amenities` — **OBJECT** 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_types` — **OBJECT** with boolean flags: `{E10, E5, B7_Standard, B7_Premium, B10, HVO}`. Normalised at ingest to a flat array of enabled keys.
|
||
- `opening_times` — `usual_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.
|