Add fuel API ingestion and historic storage design spec
Includes verified API authentication flow, correct base URL, all DB table schemas for stations, current prices, history, and archive. Fuel types corrected to match live API (B7_STANDARD, B7_PREMIUM). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
432
.claude/rules/api-data.md
Normal file
432
.claude/rules/api-data.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# 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 15 minutes via scheduler (incremental), full refresh once daily
|
||||
|
||||
### 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}`
|
||||
|
||||
Fuel Finder REST API
|
||||
The Fuel Finder API is a REST API that gives a simple, consistent way to request, create and update data. REST stands for Representational State Transfer which is an architectural software style in which standard HTTP request methods are used to retrieve and modify representations of data. This is identical to the process of retrieving a web page or submitting a web form.
|
||||
|
||||
Representational State Transfer (REST) web services
|
||||
In a RESTful API, each data resource has a unique URL and is manipulated using standard HTTP verbs such as:
|
||||
|
||||
GET to request a resource
|
||||
POST to create a resource (not used for read-only endpoints)
|
||||
PUT to change a resource (not used for read-only endpoints)
|
||||
DELETE to remove a resource (not used for read-only endpoints)
|
||||
Example: request a price resource
|
||||
GET: https://api.fuelfinder.service.gov.uk/v1/prices/GB-12345 HTTP/1.1
|
||||
The request uses GET and does not include a request body.
|
||||
|
||||
In a RESTful API, a resource is modified by POSTing a revised resource representation, in this case JSON, to the same resource URL:
|
||||
|
||||
POST: https://api.fuelfinder.service.gov.uk/v1/<endpoint>
|
||||
Content-Type: text/json
|
||||
{
|
||||
"CustomerName": "Joe Bloggs",
|
||||
"Address": "",
|
||||
"etc": etc
|
||||
}
|
||||
REST builds on the features of HTTP. Because each resource has a globally unique URL and can be fetched with GET, REST APIs can benefit from existing network components such as caches and proxies.
|
||||
|
||||
The JSON data format
|
||||
Responses use JSON (JavaScript Object Notation). JSON is a compact, widely used format for storing and exchanging data. Most programming languages support JSON, which makes it well suited to HTTP-based API services.
|
||||
|
||||
#### Endpoints
|
||||
- Endpoints
|
||||
- Method Endpoint
|
||||
- GET Fetch all PFS fuel prices
|
||||
- GET Fetch incremental PFS fuel prices
|
||||
- GET Fetch PFS information
|
||||
- GET Fetch incremental PFS information
|
||||
|
||||
|
||||
```
|
||||
https://www.fuel-finder.service.gov.uk/api/v1/pfs/fuel-prices?batch-number
|
||||
[
|
||||
{
|
||||
"node_id": "0028acef5f3afc41c7e7d56fb285a940dfb64d6fea01cb4accd79c148321112d",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "Alex Fuel Station",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "E10",
|
||||
"price": 132.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "B7_STANDARD",
|
||||
"price": 141.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "01da92125c3751767044d06b202f45da5933f0e16e256fa3e98a16af8386308d",
|
||||
"public_phone_number": "",
|
||||
"trading_name": "Star Garage",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "020592cd81196efdb61ab2135f837ddf3d2bee4e64346810270f0b088b4c09d8",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "Blue Hills Fuel Station",
|
||||
"fuel_prices": [
|
||||
{
|
||||
"fuel_type": "E5",
|
||||
"price": 159.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
},
|
||||
{
|
||||
"fuel_type": "B7_STANDARD",
|
||||
"price": 141.9,
|
||||
"price_last_updated": "2026-02-17T16:03:04.938Z",
|
||||
"price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
```
|
||||
https://www.fuel-finder.service.gov.uk/api/v1/pfs?batch-number=1
|
||||
[
|
||||
{
|
||||
"node_id": "9b275ab576eeba3c6677984be15ee22a74e54fdfe8e5ea700e84a03178dc4ac1",
|
||||
"public_phone_number": null,
|
||||
"trading_name": "TEST",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "TEST",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": false,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "HALL & WOODHOUSE, TAPLOW BOATYARD, MILL LANE, TAPLOW, MAIDENHEAD, SL6 0AA",
|
||||
"address_line_2": null,
|
||||
"city": "MAIDENHEAD",
|
||||
"country": "England",
|
||||
"county": null,
|
||||
"postcode": "SL6 0AA",
|
||||
"latitude": 51.5268585,
|
||||
"longitude": -0.700361
|
||||
},
|
||||
"amenities": [
|
||||
"water_filling"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "00:00:00",
|
||||
"close": "00:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "00:00:00",
|
||||
"close": "23:59:00",
|
||||
"is_24_hours": true
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "bank holiday",
|
||||
"open_time": "00:00:00",
|
||||
"close_time": "00:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"E10",
|
||||
"E5",
|
||||
"HVO",
|
||||
"B10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "4fd9a4c6b48358b9b5c95989fba100fdcbb87c9e909ed4ce1ad96f64ffb8b56a",
|
||||
"public_phone_number": "+44 7723608248",
|
||||
"trading_name": "TEST FORECOURT 1",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "TEXACO ONE",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": null,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "NEWPORT",
|
||||
"address_line_2": "",
|
||||
"city": "BROUGH",
|
||||
"country": "ENGLAND",
|
||||
"county": "EAST YORKSHIRE",
|
||||
"postcode": "HU15 2RD",
|
||||
"latitude": 51.258503,
|
||||
"longitude": -3.417567
|
||||
},
|
||||
"amenities": [
|
||||
"adblue_packaged",
|
||||
"adblue_pumps",
|
||||
"car_wash",
|
||||
"customer_toilets"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "06:00:01",
|
||||
"close": "23:00:01",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "standard",
|
||||
"open_time": "06:00:01",
|
||||
"close_time": "23:00:01",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"B10"
|
||||
]
|
||||
},
|
||||
{
|
||||
"node_id": "91bdda1c07fa05110a31639cc66932f9ed8bd388d4f6be542a423365bcfd53e1",
|
||||
"public_phone_number": "+442071930000",
|
||||
"trading_name": "SUPERFUEL LOUGHBOROUGH 12",
|
||||
"is_same_trading_and_brand_name": true,
|
||||
"brand_name": "SUPERFUEL STATION 4",
|
||||
"temporary_closure": false,
|
||||
"permanent_closure": null,
|
||||
"permanent_closure_date": null,
|
||||
"is_motorway_service_station": false,
|
||||
"is_supermarket_service_station": false,
|
||||
"location": {
|
||||
"address_line_1": "14 LONDON ROAD",
|
||||
"address_line_2": "FUELVILLE",
|
||||
"city": "LOUGHBOROUGH",
|
||||
"country": "ENGLAND",
|
||||
"county": "LEICESTERSHIRE",
|
||||
"postcode": "LE11 9AA",
|
||||
"latitude": 50.503343,
|
||||
"longitude": -2.12444
|
||||
},
|
||||
"amenities": [
|
||||
"adblue_packaged",
|
||||
"adblue_pumps",
|
||||
"car_wash",
|
||||
"customer_toilets",
|
||||
"water_filling"
|
||||
],
|
||||
"opening_times": {
|
||||
"usual_days": {
|
||||
"monday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"tuesday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"wednesday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"thursday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"friday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"saturday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
},
|
||||
"sunday": {
|
||||
"open": "06:00:00",
|
||||
"close": "22:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"bank_holiday": {
|
||||
"type": "standard",
|
||||
"open_time": "08:00:00",
|
||||
"close_time": "20:00:00",
|
||||
"is_24_hours": false
|
||||
}
|
||||
},
|
||||
"fuel_types": [
|
||||
"E5",
|
||||
"HVO",
|
||||
"B10",
|
||||
"B7_PREMIUM",
|
||||
"B7_STANDARD"
|
||||
]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 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 — postcode → lat/lng
|
||||
|
||||
- URL: `https://api.postcodes.io/postcodes/{postcode}`
|
||||
- Free, no API key required
|
||||
- Called once on user registration / when postcode changes
|
||||
- Store resolved `lat` + `lng` on `users` table
|
||||
- Cache postcode lookups for 30 days (postcodes rarely change coordinates)
|
||||
|
||||
## FRED API (St. Louis Fed) — Brent crude direction
|
||||
|
||||
- Series: `DCOILBRENTEU` (daily Brent spot price)
|
||||
- URL: `https://api.stlouisfed.org/fred/series/observations?series_id=DCOILBRENTEU&api_key={key}&sort_order=desc&limit=10&file_type=json`
|
||||
- Free API key required — stored as `FRED_API_KEY` in .env
|
||||
- Fetched once daily via scheduler at 7am
|
||||
- Stored in `brent_prices` table: `(date DATE, price_usd DECIMAL(8,2))`
|
||||
- Only the 5-day trend direction is used by the scoring engine
|
||||
|
||||
## 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.
|
||||
53
.claude/rules/architecture.md
Normal file
53
.claude/rules/architecture.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Architecture
|
||||
|
||||
## Core principle: fat Services, thin everything else
|
||||
|
||||
All business logic lives in Service classes. Controllers, Livewire components,
|
||||
and console commands are thin orchestrators — they call Services and return results.
|
||||
This keeps the app API-extractable later without a rewrite.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── Console/Commands/ # Scheduler commands (PollFuelPrices, RunScoringEngine)
|
||||
├── Http/Controllers/ # Minimal — auth + Stripe webhook only
|
||||
├── Livewire/ # Classic two-file Livewire components
|
||||
├── Models/ # Eloquent models
|
||||
├── Notifications/ # Laravel Notification classes (multi-channel)
|
||||
├── Services/ # ALL business logic lives here
|
||||
│ ├── FuelPriceService.php # Fuel Finder API polling + storage
|
||||
│ ├── AlertScoringService.php # Fill-up timing recommendation engine
|
||||
│ ├── StationTaggingService.php # Supermarket brand detection
|
||||
│ ├── NotificationDispatchService.php # Tier-aware notification routing
|
||||
│ └── SubscriptionService.php # Cashier/tier helpers
|
||||
└── Jobs/ # Queued jobs (dispatch notifications per user)
|
||||
|
||||
resources/views/
|
||||
├── livewire/ # Livewire Blade templates
|
||||
└── emails/ # Mailable templates
|
||||
|
||||
routes/
|
||||
├── web.php # All web routes (Livewire pages)
|
||||
└── api.php # Empty for now — API added later if needed
|
||||
```
|
||||
|
||||
## Service class conventions
|
||||
|
||||
- Constructor injection only — no facade usage inside Services
|
||||
- Services are bound in AppServiceProvider if they need interfaces
|
||||
- Each Service has one responsibility — do not merge concerns
|
||||
- Return typed DTOs or Eloquent collections — never raw arrays from Services
|
||||
- Services never dispatch jobs directly — that's the controller/command's job
|
||||
|
||||
## No API yet
|
||||
|
||||
`routes/api.php` stays empty for v1. Do not create API controllers or Sanctum
|
||||
token auth. The app is Livewire-only until subscriber count justifies a native app.
|
||||
When the API is added later, it will reuse the same Service classes.
|
||||
|
||||
## Livewire components (classic only)
|
||||
|
||||
Use two-file classic Livewire components. Do NOT use Volt single-file syntax.
|
||||
Volt files from the starter kit (auth screens) are left as-is — do not convert them.
|
||||
New components go in `app/Livewire/` with corresponding Blade in `resources/views/livewire/`.
|
||||
98
.claude/rules/database.md
Normal file
98
.claude/rules/database.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Database
|
||||
|
||||
## Engine and conventions
|
||||
|
||||
- MySQL with InnoDB. All schema changes via Laravel migrations only — no raw DDL.
|
||||
- All models use Eloquent. No raw DB:: queries except for complex aggregations.
|
||||
- Table names: plural snake_case. Column names: snake_case.
|
||||
- Always define `$fillable` or `$guarded` on models — never leave both empty.
|
||||
- Use `->comment()` on migration columns for non-obvious fields.
|
||||
|
||||
## Core tables
|
||||
|
||||
### users
|
||||
Standard Laravel users table + additions:
|
||||
- `postcode` VARCHAR(8) — user's home postcode, used for nearby station lookup
|
||||
- `lat` / `lng` DECIMAL(10,7) — resolved from postcode via postcodes.io on registration
|
||||
- `whatsapp_number` VARCHAR(20) NULLABLE — verified mobile, set after OTP flow
|
||||
- `whatsapp_verified_at` TIMESTAMP NULLABLE
|
||||
- `push_token` VARCHAR(255) NULLABLE — OneSignal player ID
|
||||
|
||||
### subscriptions
|
||||
Managed by Laravel Cashier — do not hand-edit this table.
|
||||
Tier is read via `$user->subscribed('basic')`, `->subscribed('plus')`, `->subscribed('pro')`.
|
||||
|
||||
### station_prices (HIGH VOLUME — partitioned)
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
station_id VARCHAR(64) — Fuel Finder API station identifier
|
||||
fuel_type ENUM('e10','e5','b7_standard','b7_premium','b10','hvo')
|
||||
price_pence SMALLINT UNSIGNED — e.g. 14523 = 145.23p (store as integer × 100)
|
||||
is_supermarket TINYINT(1) DEFAULT 0
|
||||
brand VARCHAR(64) NULLABLE
|
||||
recorded_at DATETIME
|
||||
|
||||
INDEX (station_id, fuel_type, recorded_at)
|
||||
INDEX (recorded_at)
|
||||
PARTITION BY RANGE (YEAR(recorded_at) * 100 + MONTH(recorded_at))
|
||||
```
|
||||
Partition monthly. Use `recorded_at` not `created_at` — it reflects actual price time.
|
||||
Never store floats for prices — always pence as SMALLINT (price × 100).
|
||||
|
||||
### stations (reference/cache)
|
||||
```
|
||||
station_id VARCHAR(64) PRIMARY KEY — from Fuel Finder API
|
||||
name VARCHAR(128)
|
||||
brand VARCHAR(64) NULLABLE
|
||||
lat DECIMAL(10,7)
|
||||
lng DECIMAL(10,7)
|
||||
postcode VARCHAR(8)
|
||||
is_supermarket TINYINT(1) DEFAULT 0
|
||||
amenities JSON NULLABLE
|
||||
last_seen_at DATETIME
|
||||
```
|
||||
Refreshed on each Fuel Finder poll. `is_supermarket` set by StationTaggingService.
|
||||
|
||||
### alerts (sent notification log)
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
user_id BIGINT UNSIGNED FK users.id
|
||||
alert_type ENUM('fill_up_now','wait','watchdog','price_drop')
|
||||
channel ENUM('email','whatsapp','sms','push')
|
||||
signal_strength TINYINT — 1=weak, 2=medium, 3=strong
|
||||
payload JSON — recommendation data snapshot
|
||||
sent_at DATETIME
|
||||
INDEX (user_id, sent_at)
|
||||
```
|
||||
|
||||
### scoring_results (daily per-user cache)
|
||||
```
|
||||
user_id BIGINT UNSIGNED FK users.id
|
||||
scored_at DATE
|
||||
recommendation ENUM('fill_up','wait','no_signal')
|
||||
confidence TINYINT — 0–100
|
||||
signals JSON — breakdown of each signal used
|
||||
local_avg_pence SMALLINT UNSIGNED
|
||||
trend_direction ENUM('falling','rising','flat')
|
||||
expires_at DATETIME
|
||||
PRIMARY KEY (user_id, scored_at)
|
||||
```
|
||||
|
||||
### otp_verifications
|
||||
```
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT
|
||||
user_id BIGINT UNSIGNED FK users.id
|
||||
channel ENUM('whatsapp','sms')
|
||||
phone_number VARCHAR(20)
|
||||
code CHAR(6)
|
||||
expires_at DATETIME
|
||||
used_at DATETIME NULLABLE
|
||||
INDEX (user_id, expires_at)
|
||||
```
|
||||
OTP codes expire after 10 minutes. Mark `used_at` on success — never delete rows.
|
||||
|
||||
## Supermarket brands (StationTaggingService)
|
||||
|
||||
Match station `name` (case-insensitive) against:
|
||||
`['tesco', 'asda', 'morrisons', 'sainsbury', 'aldi', 'lidl', 'costco']`
|
||||
Set `is_supermarket = 1` and normalise `brand` on match.
|
||||
61
.claude/rules/livewire.md
Normal file
61
.claude/rules/livewire.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Livewire Components
|
||||
|
||||
## Classic two-file components only
|
||||
|
||||
Do NOT use Volt single-file syntax for new components.
|
||||
Volt files created by the Livewire starter kit (auth screens) are left untouched.
|
||||
|
||||
## Component locations
|
||||
|
||||
```
|
||||
app/Livewire/
|
||||
├── Dashboard/
|
||||
│ ├── FuelRecommendation.php # Main fill-up/wait card
|
||||
│ ├── NearbyStations.php # Map + station list
|
||||
│ └── PriceHistory.php # 14-day trend chart
|
||||
├── Account/
|
||||
│ ├── NotificationSettings.php # Channel prefs + WhatsApp OTP
|
||||
│ ├── SubscriptionManager.php # Upgrade/downgrade UI
|
||||
│ └── FuelPreferences.php # Fuel type, postcode
|
||||
└── Public/
|
||||
└── PriceWatchdog.php # Public-facing local price watchdog
|
||||
```
|
||||
|
||||
## Component conventions
|
||||
|
||||
- Component properties that are shown in the view must be `public`
|
||||
- Use `#[Computed]` attribute for derived values — not re-computed on every render
|
||||
- Validate with `#[Validate]` attribute on properties, not in separate rules array
|
||||
- Never put Service instantiation in the component — inject via method parameter or mount()
|
||||
- Dispatch browser events with `$this->dispatch()` not `$this->emit()` (Livewire 3 syntax)
|
||||
- Use `wire:loading` on all buttons that trigger server actions
|
||||
|
||||
## Alpine.js usage
|
||||
|
||||
Alpine.js handles local UI state only: dropdowns, modals, tab switching, copy-to-clipboard.
|
||||
Do not replicate server state in Alpine — use Livewire for anything that needs PHP.
|
||||
Alpine components stay inline in Blade (`x-data="{}"`), not in separate JS files unless reused 3+ times.
|
||||
|
||||
## Map (Leaflet.js)
|
||||
|
||||
Leaflet is a plain JS drop-in, not a Livewire component.
|
||||
Station data is fetched from a dedicated Livewire endpoint and passed to Leaflet via Alpine:
|
||||
```blade
|
||||
<div
|
||||
x-data="stationMap(@entangle('stations'))"
|
||||
id="map"
|
||||
style="height: 400px"
|
||||
></div>
|
||||
```
|
||||
Map initialisation lives in `resources/js/maps/station-map.js`.
|
||||
|
||||
## Page routing
|
||||
|
||||
Livewire full-page components are mounted in `routes/web.php` using `Route::get()->component()`.
|
||||
No separate view files for pages — the Livewire component IS the page.
|
||||
|
||||
---
|
||||
paths:
|
||||
- "app/Livewire/**/*.php"
|
||||
- "resources/views/livewire/**/*.blade.php"
|
||||
---
|
||||
68
.claude/rules/notifications.md
Normal file
68
.claude/rules/notifications.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Notifications
|
||||
|
||||
## Tier-to-channel mapping
|
||||
|
||||
| Tier | Price | Email | Push (PWA) | WhatsApp | SMS |
|
||||
|------------|--------|-------|------------|----------|-----------|
|
||||
| Free | £0 | ✓ weekly digest | ✗ | ✗ | ✗ |
|
||||
| Basic | £0.99 | ✓ daily | ✓ daily | ✓ daily | ✗ |
|
||||
| Plus | £2.49 | ✓ | ✓ | ✓ | ✓ max 1/day triggered |
|
||||
| Pro | £3.99 | ✓ | ✓ | ✓ | ✓ max 3/day triggered |
|
||||
|
||||
## NotificationDispatchService
|
||||
|
||||
Reads user tier via Cashier, returns allowed channels:
|
||||
```php
|
||||
public function channelsFor(User $user): array
|
||||
// Returns e.g. ['mail', 'vonage-whatsapp'] for Basic tier
|
||||
// Returns ['mail', 'vonage-whatsapp', 'vonage-sms'] for Plus+
|
||||
```
|
||||
|
||||
Never fire SMS unless confidence ≥ 70 AND user is Plus/Pro.
|
||||
Never fire more than 1 notification per user per day unless they are Pro tier.
|
||||
Log every sent notification to `alerts` table.
|
||||
|
||||
## Laravel Notification class
|
||||
|
||||
One `FuelPriceAlert` notification class with `via()` returning channels based on tier.
|
||||
Subject line / message copy adapts based on `recommendation` and `confidence`.
|
||||
|
||||
## WhatsApp (Vonage)
|
||||
|
||||
- Provider: Vonage Messages API (WhatsApp channel)
|
||||
- PHP package: `vonage/client`
|
||||
- Template: utility category (one-way alert) — pre-approved template required by Meta
|
||||
- Opt-in: phone number + OTP flow (see otp_verifications table)
|
||||
- User must have `whatsapp_verified_at` set — never send to unverified numbers
|
||||
- Cost: ~0.3–0.5p per message (utility rate)
|
||||
- Message format: keep under 160 chars. Include station name, price, and recommendation reason.
|
||||
|
||||
## SMS (Vonage — Plus/Pro only)
|
||||
|
||||
- Same Vonage client, SMS channel
|
||||
- Triggered only (not daily) — fires when signal strength ≥ 2 AND price event warrants it
|
||||
- Plus: max 8 SMS/month (enforced via alerts table count)
|
||||
- Pro: max 30 SMS/month
|
||||
- Cost: ~3.5p per message UK
|
||||
|
||||
## Email
|
||||
|
||||
- Driver: Mailgun (configured in .env)
|
||||
- Free tier: weekly digest every Monday 8am
|
||||
- Basic+: daily summary if recommendation is not `no_signal`
|
||||
- Use Mailable classes, not raw Mail::send
|
||||
- Templates in `resources/views/emails/`
|
||||
|
||||
## Push notifications (OneSignal)
|
||||
|
||||
- Free plan: up to 10,000 subscribers
|
||||
- Player ID stored in `users.push_token`
|
||||
- Use OneSignal REST API — no official Laravel package needed, simple HTTP call
|
||||
- PWA service worker setup required (separate task)
|
||||
- Basic+: daily push alongside email
|
||||
|
||||
## Queue
|
||||
|
||||
All notifications are dispatched as queued jobs — never send synchronously.
|
||||
Use `FuelAlertNotificationJob` dispatched per user.
|
||||
Queue: `notifications` (separate from default queue for priority control).
|
||||
54
.claude/rules/payments.md
Normal file
54
.claude/rules/payments.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Payments & Subscriptions
|
||||
|
||||
## Stack
|
||||
|
||||
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
||||
|
||||
## Stripe products
|
||||
|
||||
Three recurring subscription products (monthly):
|
||||
- `basic` — £0.99/mo
|
||||
- `plus` — £2.49/mo
|
||||
- `pro` — £3.99/mo
|
||||
|
||||
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
|
||||
```
|
||||
STRIPE_PRICES_BASIC=price_xxx
|
||||
STRIPE_PRICES_PLUS=price_xxx
|
||||
STRIPE_PRICES_PRO=price_xxx
|
||||
```
|
||||
|
||||
## Tier helpers (SubscriptionService)
|
||||
|
||||
```php
|
||||
public function tier(User $user): string
|
||||
// Returns 'free' | 'basic' | 'plus' | 'pro'
|
||||
|
||||
public function canReceiveSms(User $user): bool
|
||||
// true if tier is plus or pro
|
||||
|
||||
public function smsRemainingThisMonth(User $user): int
|
||||
// checks alerts table count for current month
|
||||
```
|
||||
|
||||
Never check tier inline in components or notification classes — always use SubscriptionService.
|
||||
|
||||
## Cashier conventions
|
||||
|
||||
- Billable model: `User` (add `use Billable` trait)
|
||||
- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically
|
||||
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||
- Always handle `customer.subscription.deleted` to downgrade user to free tier
|
||||
- Trial: none for v1
|
||||
|
||||
## Upgrade / downgrade flow
|
||||
|
||||
- User upgrades in account settings Livewire component
|
||||
- Swap plan with `$user->subscription()->swap($newPriceId)`
|
||||
- Cashier handles proration automatically
|
||||
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
|
||||
|
||||
## Stripe test mode
|
||||
|
||||
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
||||
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
|
||||
76
.claude/rules/scoring.md
Normal file
76
.claude/rules/scoring.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Scoring Engine (AlertScoringService)
|
||||
|
||||
## Purpose
|
||||
|
||||
Produces a "fill up now or wait?" recommendation per user based on their local
|
||||
station history. Output is one of: `fill_up`, `wait`, `no_signal`.
|
||||
Never guess — stay silent (no_signal) when signals conflict or data is insufficient.
|
||||
|
||||
## The 4 signals (in priority order)
|
||||
|
||||
### Signal 1 — Local price trend (HIGHEST WEIGHT)
|
||||
- Query `station_prices` for user's nearest 5 stations (within 5km of user lat/lng)
|
||||
- Use last 14 days of history for `e10` (or user's preferred fuel type)
|
||||
- Calculate 3-day rolling average vs 7-day rolling average
|
||||
- **Falling**: 3-day avg < 7-day avg by ≥ 0.5p → positive wait signal
|
||||
- **Rising**: 3-day avg > 7-day avg by ≥ 0.5p → fill_up signal
|
||||
- **Flat**: difference < 0.5p → neutral, no signal
|
||||
- Weight: 40 points max
|
||||
|
||||
### Signal 2 — Supermarket anchor effect (HIGH WEIGHT)
|
||||
- Find nearest supermarket station (is_supermarket = 1) within 10km
|
||||
- Check if supermarket cut price in last 48 hours (> 1p drop)
|
||||
- Check if nearest non-supermarket stations have NOT yet followed
|
||||
- If supermarket cut AND independents haven't moved → strong wait signal
|
||||
- Weight: 35 points max
|
||||
|
||||
### Signal 3 — Day-of-week pattern (MEDIUM WEIGHT — needs 8+ weeks data)
|
||||
- Per station: average price by day-of-week over last 90 days
|
||||
- Only activate if station has 56+ days of history
|
||||
- If today is statistically 1.5p+ cheaper than weekly average → mild fill_up
|
||||
- If today is statistically 1.5p+ more expensive → mild wait
|
||||
- Weight: 15 points max
|
||||
|
||||
### Signal 4 — Brent crude direction (LOW WEIGHT)
|
||||
- Fetched daily from FRED API, stored in a simple `brent_prices` table
|
||||
- 5-day trend: rising ≥ 3% → mild fill_up pressure; falling ≥ 3% → mild wait
|
||||
- Weight: 10 points max
|
||||
|
||||
## Confidence thresholds
|
||||
|
||||
- Score 70–100: strong signal → fire recommendation + notification
|
||||
- Score 40–69: weak signal → show in dashboard only, no push/SMS/WhatsApp
|
||||
- Score 0–39: no_signal → stay silent entirely
|
||||
|
||||
**Only send notifications when confidence ≥ 70.** Never spam.
|
||||
|
||||
## Output (stored in scoring_results)
|
||||
|
||||
```php
|
||||
[
|
||||
'recommendation' => 'wait', // fill_up | wait | no_signal
|
||||
'confidence' => 78, // 0-100
|
||||
'signals' => [
|
||||
'trend' => ['direction' => 'falling', 'points' => 32],
|
||||
'supermarket' => ['triggered' => true, 'points' => 35],
|
||||
'day_pattern' => ['triggered' => false, 'points' => 0],
|
||||
'brent' => ['direction' => 'flat', 'points' => 0],
|
||||
],
|
||||
'local_avg_pence' => 14380, // 143.80p
|
||||
'trend_delta' => -2.3, // pence change over 7 days
|
||||
]
|
||||
```
|
||||
|
||||
## Human-readable reason strings
|
||||
|
||||
Always generate a plain-English reason for the recommendation:
|
||||
- "Prices near you have been falling for 6 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
|
||||
- "Prices are rising in your area — filling up today avoids paying more later."
|
||||
- "No clear pattern this week — fill up at the cheapest station near you now."
|
||||
|
||||
Reason strings are stored in `scoring_results.signals` JSON and shown in the UI and notifications.
|
||||
|
||||
## Accuracy self-tracking
|
||||
|
||||
After 3 days, check if `wait` recommendation was correct (prices did fall further).
|
||||
Store outcome in `scoring_results` for future display: "This signal has been right X% of the time in your area."
|
||||
48
.claude/rules/testing.md
Normal file
48
.claude/rules/testing.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Testing
|
||||
|
||||
## Framework
|
||||
|
||||
Pest PHP. Never use PHPUnit test classes directly — always Pest syntax.
|
||||
|
||||
## Test structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── Feature/
|
||||
│ ├── Scoring/AlertScoringServiceTest.php
|
||||
│ ├── Notifications/NotificationDispatchTest.php
|
||||
│ ├── Payments/SubscriptionTest.php
|
||||
│ └── Livewire/DashboardTest.php
|
||||
└── Unit/
|
||||
├── Services/FuelPriceServiceTest.php
|
||||
└── Services/StationTaggingServiceTest.php
|
||||
```
|
||||
|
||||
## Conventions
|
||||
|
||||
- Use `RefreshDatabase` trait on Feature tests
|
||||
- Factory-first: all test data via Eloquent factories — never raw DB inserts
|
||||
- Mock external APIs (Fuel Finder, Vonage, OneSignal, FRED) — never hit live APIs in tests
|
||||
- Use `Http::fake()` for all outbound HTTP in tests
|
||||
- Stripe: use Cashier's built-in test helpers, never hit Stripe API in tests
|
||||
|
||||
## What to test
|
||||
|
||||
- AlertScoringService: all signal combinations, confidence thresholds, no_signal cases
|
||||
- NotificationDispatchService: correct channels returned per tier
|
||||
- SubscriptionService: tier detection, SMS limit counting
|
||||
- Livewire components: use `Livewire::test()` for interaction testing
|
||||
- Stripe webhooks: test `customer.subscription.deleted` downgrades user correctly
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
php artisan test # Full suite
|
||||
php artisan test --filter=Scoring # Single test class
|
||||
php artisan test --parallel # Parallel (faster)
|
||||
```
|
||||
|
||||
---
|
||||
paths:
|
||||
- "tests/**/*.php"
|
||||
---
|
||||
Reference in New Issue
Block a user