# 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.

### brent_prices (daily Brent crude from FRED)
```
date            DATE PRIMARY KEY   — trading day (no weekends/holidays)
price_usd       DECIMAL(8,2)       — spot price USD per barrel
```
Populated daily by `OilPriceService::fetchBrentPrices()` via FRED API.
FRED returns `"."` for non-trading days — these are filtered out before insert.
Upserted on refetch so duplicate dates never occur.

### price_predictions (oil price direction forecast)
```
id              BIGINT UNSIGNED AUTO_INCREMENT
predicted_for   DATE               — the date this prediction covers
source          ENUM('llm','ewma') — which method generated it
direction       ENUM('rising','falling','flat')
confidence      TINYINT            — 0–100 (LLM max 85, EWMA max 65)
reasoning       TEXT NULLABLE      — plain-English explanation
generated_at    DATETIME

INDEX (predicted_for, source)
```
Generated daily by `OilPriceService::generatePrediction()`.
LLM (Anthropic) is tried first; EWMA is used as fallback if LLM fails or key not set.
Signal 4 in AlertScoringService reads from this table — never from brent_prices directly.

## 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.
