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:
Ovidiu U
2026-04-03 18:09:50 +01:00
parent 02d4c9d888
commit 5ad89e977d
11 changed files with 595 additions and 102 deletions

432
.claude/rules/api-data.md Normal file
View 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.

View 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
View 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 — 0100
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
View 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"
---

View 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.30.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
View 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
View 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 70100: strong signal → fire recommendation + notification
- Score 4069: weak signal → show in dashboard only, no push/SMS/WhatsApp
- Score 039: 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
View 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"
---