Compare commits
78 Commits
aec547cd86
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd45588e9 | ||
|
|
598ef04645 | ||
|
|
07e0789044 | ||
|
|
97e27fc057 | ||
|
|
11a3b433ff | ||
|
|
8dad223d06 | ||
|
|
1c46667f56 | ||
|
|
203200acb9 | ||
|
|
ddd591ad47 | ||
|
|
d13a29df01 | ||
|
|
c2c237a1b3 | ||
|
|
25cf022964 | ||
|
|
e821a934a5 | ||
|
|
73de53994f | ||
|
|
df70e514e9 | ||
|
|
28061541d4 | ||
|
|
895d55439b | ||
|
|
aff6dd1e0f | ||
|
|
06f5f2035f | ||
|
|
69eb524e07 | ||
|
|
b4ef1177b2 | ||
|
|
8e29980dfe | ||
|
|
4ce5066596 | ||
|
|
c46b017b51 | ||
|
|
7f64c42a23 | ||
|
|
4d9df1ee19 | ||
|
|
5369b4a5a0 | ||
|
|
27c82ef103 | ||
|
|
e39618f5df | ||
|
|
00d0f7c8ec | ||
|
|
48af2083f3 | ||
|
|
783297694c | ||
|
|
775e076bb7 | ||
|
|
8695d5ec95 | ||
|
|
088fd11058 | ||
|
|
ee6de23709 | ||
|
|
2ff3aeba4d | ||
|
|
b8adb81c79 | ||
|
|
3224b186b2 | ||
|
|
36444cde05 | ||
|
|
b7175169f0 | ||
|
|
5b17f4cae4 | ||
|
|
c127cc379e | ||
|
|
de2499636f | ||
|
|
2078c4b83e | ||
|
|
b9d457578c | ||
|
|
25b79f095b | ||
|
|
a39d4b1b94 | ||
|
|
f1c1a1c572 | ||
|
|
bf013926c0 | ||
|
|
19fc61a0a3 | ||
|
|
13fc227619 | ||
|
|
d8f87f964d | ||
|
|
975a1522cf | ||
|
|
29ba2f3d86 | ||
|
|
3ec7cda790 | ||
|
|
d01a634f0b | ||
|
|
9ad62538b9 | ||
|
|
4a60298606 | ||
|
|
5426722c71 | ||
|
|
d460de1850 | ||
|
|
45bf1c0d24 | ||
|
|
1e3b246172 | ||
|
|
9fa9ea7835 | ||
|
|
55c81fab7b | ||
|
|
64a7cc3de5 | ||
|
|
7c114c72e4 | ||
|
|
2fe9c3ef77 | ||
|
|
b4bd78ab4c | ||
|
|
8335f49fd6 | ||
|
|
dd9bd95657 | ||
|
|
afe459f248 | ||
|
|
d822b77fb0 | ||
|
|
831637380c | ||
|
|
c2466e5a61 | ||
|
|
7dc41ba9ee | ||
|
|
d29f3e6487 | ||
|
|
5acb99c9e3 |
BIN
.claude/.DS_Store
vendored
BIN
.claude/.DS_Store
vendored
Binary file not shown.
@@ -5,7 +5,7 @@
|
|||||||
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
|
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
|
||||||
- Returns: all UK station prices + station metadata (~14,500 stations)
|
- Returns: all UK station prices + station metadata (~14,500 stations)
|
||||||
- Update frequency: stations report within 30 minutes of price change
|
- Update frequency: stations report within 30 minutes of price change
|
||||||
- Our polling interval: every 15 minutes via scheduler (incremental), full refresh once daily
|
- 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
|
### Authentication
|
||||||
|
|
||||||
@@ -35,24 +35,34 @@ Content-Type: application/json
|
|||||||
- Include token in every API request: `Authorization: Bearer {token}`
|
- Include token in every API request: `Authorization: Bearer {token}`
|
||||||
|
|
||||||
#### Endpoints
|
#### Endpoints
|
||||||
- `GET /api/v1/pfs/fuel-prices?batch-number` — all/incremental station prices
|
- `GET /api/v1/pfs/fuel-prices?batch-number={n}` — all station prices (500 stations per batch)
|
||||||
- `GET /api/v1/pfs?batch-number` — all/incremental station metadata
|
- `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):
|
**Fuel prices response fields** (array of stations):
|
||||||
- `node_id` — station identifier
|
- `node_id`, `public_phone_number`, `trading_name` — station identifiers
|
||||||
- `trading_name` — station name
|
|
||||||
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
|
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
|
||||||
- Fuel types: `E5`, `E10`, `B7_STANDARD`, `B7_PREMIUM`, `B10`, `HVO`
|
- 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
|
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
|
||||||
|
|
||||||
**Station metadata response fields** (array of stations):
|
**Station metadata response fields** (array of stations):
|
||||||
- `node_id`, `trading_name`, `brand_name`
|
- `node_id`, `trading_name`, `brand_name`, `is_same_trading_and_brand_name`, `public_phone_number`
|
||||||
- `is_supermarket_service_station`, `is_motorway_service_station`
|
- `is_supermarket_service_station`, `is_motorway_service_station`
|
||||||
- `temporary_closure`, `permanent_closure`
|
- `temporary_closure`, `permanent_closure`, `permanent_closure_date`
|
||||||
- `location` — `{address_line_1, city, postcode, latitude, longitude}`
|
- `location` — `{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}`
|
||||||
- `amenities` — string array (e.g. `car_wash`, `adblue_pumps`)
|
- `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` — string array of available fuel types
|
- `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` — per-day open/close times (not used in scoring)
|
- `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
|
### FuelPriceService responsibilities
|
||||||
1. Fetch OAuth token (cache it)
|
1. Fetch OAuth token (cache it)
|
||||||
@@ -70,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
|
|||||||
```
|
```
|
||||||
FUEL_FINDER_CLIENT_ID=
|
FUEL_FINDER_CLIENT_ID=
|
||||||
FUEL_FINDER_CLIENT_SECRET=
|
FUEL_FINDER_CLIENT_SECRET=
|
||||||
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk
|
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
## Postcodes.io — location resolution
|
## Postcodes.io — location resolution
|
||||||
|
|||||||
@@ -4,51 +4,121 @@
|
|||||||
|
|
||||||
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
|
||||||
|
|
||||||
## Stripe products
|
## Source-of-truth spec
|
||||||
|
|
||||||
|
`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
|
||||||
|
defines the full subscription lifecycle. This file is a quick-reference; the
|
||||||
|
spec document is authoritative on any contradiction.
|
||||||
|
|
||||||
|
## Stripe products & prices
|
||||||
|
|
||||||
|
Three recurring subscription products, each with monthly and annual prices:
|
||||||
|
|
||||||
Three recurring subscription products (monthly):
|
|
||||||
- `basic` — £0.99/mo
|
- `basic` — £0.99/mo
|
||||||
- `plus` — £2.49/mo
|
- `plus` — £2.49/mo
|
||||||
- `pro` — £3.99/mo
|
- `pro` — £3.99/mo
|
||||||
|
|
||||||
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env:
|
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`:
|
||||||
|
|
||||||
```
|
```
|
||||||
STRIPE_PRICES_BASIC=price_xxx
|
STRIPE_PRICE_BASIC_MONTHLY=price_xxx
|
||||||
STRIPE_PRICES_PLUS=price_xxx
|
STRIPE_PRICE_BASIC_ANNUAL=price_xxx
|
||||||
STRIPE_PRICES_PRO=price_xxx
|
STRIPE_PRICE_PLUS_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PLUS_ANNUAL=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_MONTHLY=price_xxx
|
||||||
|
STRIPE_PRICE_PRO_ANNUAL=price_xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tier helpers (SubscriptionService)
|
Resolution from a Cashier subscription's Stripe price ID to a plan row is done
|
||||||
|
in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere.
|
||||||
|
|
||||||
```php
|
## Tier resolution
|
||||||
public function tier(User $user): string
|
|
||||||
// Returns 'free' | 'basic' | 'plus' | 'pro'
|
|
||||||
|
|
||||||
public function canReceiveSms(User $user): bool
|
Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`.
|
||||||
// true if tier is plus or pro
|
Never inspect `$user->subscribed(...)` directly in components, notifications, or
|
||||||
|
jobs. `PlanFeatures` is the single source of entitlement truth.
|
||||||
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
|
## Cashier conventions
|
||||||
|
|
||||||
- Billable model: `User` (add `use Billable` trait)
|
- Billable model: `User` (uses `Billable` trait)
|
||||||
- Webhook route: `POST /stripe/webhook` — handled by Cashier automatically
|
- Webhook route: `POST /stripe/webhook` — auto-registered by Cashier
|
||||||
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
|
||||||
- Always handle `customer.subscription.deleted` to downgrade user to free tier
|
- `STRIPE_KEY` and `STRIPE_SECRET` also required
|
||||||
- Trial: none for v1
|
- `CASHIER_CURRENCY=gbp`
|
||||||
|
- Trial period: none
|
||||||
|
|
||||||
## Upgrade / downgrade flow
|
## User-facing flows — all via Stripe Customer Portal
|
||||||
|
|
||||||
- User upgrades in account settings Livewire component
|
**The Stripe-hosted Customer Billing Portal handles every subscription
|
||||||
- Swap plan with `$user->subscription()->swap($newPriceId)`
|
management action.** Do not build custom Livewire upgrade/downgrade UIs.
|
||||||
- Cashier handles proration automatically
|
|
||||||
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference
|
| Flow | Path |
|
||||||
|
|---|---|
|
||||||
|
| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout |
|
||||||
|
| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately |
|
||||||
|
| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end |
|
||||||
|
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end |
|
||||||
|
| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email |
|
||||||
|
| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) |
|
||||||
|
|
||||||
|
Annual downgrades take effect at the end of the year — no mid-term refunds.
|
||||||
|
|
||||||
|
## Webhook handling
|
||||||
|
|
||||||
|
Single consolidated listener `HandleStripeWebhook` bound to Cashier's
|
||||||
|
`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`:
|
||||||
|
|
||||||
|
| Event | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
|
||||||
|
| `customer.subscription.updated` | Bust cache |
|
||||||
|
| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
|
||||||
|
| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables |
|
||||||
|
|
||||||
|
All branches must be idempotent — Stripe retries failed webhook deliveries.
|
||||||
|
|
||||||
|
`invoice.upcoming` is intentionally not handled.
|
||||||
|
|
||||||
|
## Payment failure & grace period
|
||||||
|
|
||||||
|
5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5
|
||||||
|
and **cancel the subscription** after the final failure.
|
||||||
|
|
||||||
|
- Features stay ON during grace — `past_due` is treated as subscribed by
|
||||||
|
Cashier, so `PlanFeatures::tier()` keeps returning the paid tier.
|
||||||
|
- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade.
|
||||||
|
- User can pay at any time via Stripe's dunning email link or the Customer
|
||||||
|
Portal — on success, grace is cleared automatically by the webhook.
|
||||||
|
|
||||||
|
## Dunning emails
|
||||||
|
|
||||||
|
- **Stripe sends:** payment-failed "update your card", successful-payment
|
||||||
|
receipts, upcoming-renewal reminders. Configure in Stripe dashboard.
|
||||||
|
- **We send:** branded reminder mailables on day 3 and day 5 after a
|
||||||
|
payment failure. Both mailables self-cancel by checking
|
||||||
|
`$this->user->grace_period_until === null` before sending — simpler than
|
||||||
|
cancelling queued jobs when payment recovers.
|
||||||
|
|
||||||
|
## Data model additions
|
||||||
|
|
||||||
|
- `users.grace_period_until` — nullable timestamp. Set on
|
||||||
|
`invoice.payment_failed`, cleared on `invoice.payment_succeeded` or
|
||||||
|
`customer.subscription.deleted`. Drives the dashboard past-due banner.
|
||||||
|
|
||||||
|
No other schema additions. Cashier + Stripe are the source of truth for
|
||||||
|
subscription state.
|
||||||
|
|
||||||
|
## VAT / Stripe Tax
|
||||||
|
|
||||||
|
Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at
|
||||||
|
£3.99 avg, or ~470 paying pro users).
|
||||||
|
|
||||||
## Stripe test mode
|
## Stripe test mode
|
||||||
|
|
||||||
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
Use Stripe test keys in local `.env`. Never commit real Stripe keys.
|
||||||
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
|
|
||||||
|
Test cards:
|
||||||
|
- `4242 4242 4242 4242` — success
|
||||||
|
- `4000 0000 0000 0002` — generic decline
|
||||||
|
- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)
|
||||||
|
|||||||
211
.claude/rules/prediction.md
Normal file
211
.claude/rules/prediction.md
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
# Prediction Engine
|
||||||
|
|
||||||
|
The "should I fill up now or wait?" recommendation that drives the headline,
|
||||||
|
notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php`
|
||||||
|
and is called from `Api\PredictionController`.
|
||||||
|
|
||||||
|
> The prediction is the product's selling point. Confidence calibration matters
|
||||||
|
> as much as direction — a "Wait — prices falling" headline at 30% confidence is
|
||||||
|
> worse than no recommendation at all.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
`predict(?float $lat, ?float $lng): array` returns:
|
||||||
|
|
||||||
|
| Key | Type | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `fuel_type` | string | currently always `'e10'` |
|
||||||
|
| `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) |
|
||||||
|
| `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote |
|
||||||
|
| `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon |
|
||||||
|
| `confidence_score` | float (0–100) | see "Confidence" below |
|
||||||
|
| `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` |
|
||||||
|
| `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction |
|
||||||
|
| `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback |
|
||||||
|
| `prediction_horizon_days` | int | `7` |
|
||||||
|
| `region_key` | `'national' | 'regional'` | depends on whether coords were passed |
|
||||||
|
| `methodology` | string | identifier for backtesting/auditing |
|
||||||
|
| `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) |
|
||||||
|
| `signals` | object | per-signal breakdown (see below) |
|
||||||
|
|
||||||
|
## Signals
|
||||||
|
|
||||||
|
Each signal returns `{score, confidence, direction, detail, data_points, enabled}`.
|
||||||
|
|
||||||
|
| Signal | Source | Enabled when | Score formula |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) |
|
||||||
|
| `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` |
|
||||||
|
| `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down |
|
||||||
|
| `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` |
|
||||||
|
| `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier |
|
||||||
|
| `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` |
|
||||||
|
| `national_momentum` | reserved | always disabled today | n/a |
|
||||||
|
|
||||||
|
### Oil signal — source preference
|
||||||
|
|
||||||
|
`computeOilSignal()` picks the freshest row in this order:
|
||||||
|
|
||||||
|
1. `source = 'llm_with_context'`
|
||||||
|
2. `source = 'llm'`
|
||||||
|
3. `source = 'ewma'`
|
||||||
|
|
||||||
|
`OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates
|
||||||
|
this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85,
|
||||||
|
EWMA at 65 (see `.claude/rules/api-data.md`).
|
||||||
|
|
||||||
|
The Brent oil signal is the **single biggest unlock** for confidence — it
|
||||||
|
captures world-news context (OPEC, geopolitical) that pure local price history
|
||||||
|
can't see.
|
||||||
|
|
||||||
|
### Day-of-week threshold
|
||||||
|
|
||||||
|
The original spec said 56 days. Lowered to 21 because:
|
||||||
|
- The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day
|
||||||
|
signal naturally contributes only `~0.23` confidence and lifts as more data
|
||||||
|
accumulates.
|
||||||
|
- 56 days delays the signal so long it might as well not exist for new users.
|
||||||
|
|
||||||
|
## Aggregator
|
||||||
|
|
||||||
|
`aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`.
|
||||||
|
|
||||||
|
### Weights
|
||||||
|
|
||||||
|
```
|
||||||
|
National (no coords):
|
||||||
|
trend 0.30
|
||||||
|
oil 0.25
|
||||||
|
dayOfWeek 0.20
|
||||||
|
brandBehaviour 0.15
|
||||||
|
stickiness 0.10
|
||||||
|
----
|
||||||
|
1.00
|
||||||
|
|
||||||
|
Regional (with coords):
|
||||||
|
regionalMomentum 0.35
|
||||||
|
oil 0.20
|
||||||
|
trend 0.15
|
||||||
|
dayOfWeek 0.15
|
||||||
|
brandBehaviour 0.10
|
||||||
|
stickiness 0.05
|
||||||
|
----
|
||||||
|
1.00
|
||||||
|
```
|
||||||
|
|
||||||
|
### Direction
|
||||||
|
|
||||||
|
```
|
||||||
|
directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable
|
||||||
|
directional_weight = Σ(weight) // only signals with direction ≠ stable
|
||||||
|
|
||||||
|
normalised = directional_score / directional_weight (0 if directional_weight ≈ 0)
|
||||||
|
|
||||||
|
direction = 'up' if normalised >= 0.1
|
||||||
|
'down' if normalised <= -0.1
|
||||||
|
'stable' otherwise
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stable signals do not dilute the direction vote.** They are excluded from both
|
||||||
|
the numerator and denominator. This is a key fix — previously a single weak
|
||||||
|
trend signal could be cancelled out by three "stable" signals adding weight
|
||||||
|
without contributing direction.
|
||||||
|
|
||||||
|
### Confidence
|
||||||
|
|
||||||
|
```
|
||||||
|
avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals
|
||||||
|
agreement = computeAgreement(signals, weights, final_direction) // 0..1
|
||||||
|
|
||||||
|
confidence_score = avg_signal_confidence × agreement × 100 (capped at 100)
|
||||||
|
```
|
||||||
|
|
||||||
|
**`avg_signal_confidence`** is how confident the individual signals are in
|
||||||
|
their own readings (R², sample size, model confidence). Stable signals DO
|
||||||
|
contribute here — knowing prices are stable is itself a confident answer.
|
||||||
|
|
||||||
|
**`agreement`** measures how the signals line up with the chosen direction:
|
||||||
|
- aligned signal: full credit (`signal_confidence × weight`)
|
||||||
|
- one side stable, other directional: half credit
|
||||||
|
- opposing signals: no credit
|
||||||
|
- final score: `Σ credit / Σ max_credit`
|
||||||
|
|
||||||
|
This separation is the second key fix. Previously `confidence = |normalised| × 100`
|
||||||
|
conflated "the signals point strongly somewhere" with "we're sure". Now:
|
||||||
|
- Strong signals all agreeing → high `confidence_score`
|
||||||
|
- Strong signals disagreeing → low `confidence_score`
|
||||||
|
- Weak signals → low `confidence_score` (via low individual confidences)
|
||||||
|
|
||||||
|
### Confidence labels
|
||||||
|
|
||||||
|
| `confidence_score` | `confidence_label` | UI behaviour |
|
||||||
|
|---|---|---|
|
||||||
|
| ≥ 70 | `high` | fire notification when allowed |
|
||||||
|
| 40–69 | `medium` | dashboard only |
|
||||||
|
| < 40 | `low` | dashboard only |
|
||||||
|
|
||||||
|
## Reasoning
|
||||||
|
|
||||||
|
`buildReasoning()` joins `detail` strings from enabled signals. If none have
|
||||||
|
material content, it falls back to an **action-aware** sentence:
|
||||||
|
|
||||||
|
| `direction` / `action` | Fallback |
|
||||||
|
|---|---|
|
||||||
|
| `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." |
|
||||||
|
| `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." |
|
||||||
|
| `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." |
|
||||||
|
|
||||||
|
The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling"
|
||||||
|
headlines and is no longer used.
|
||||||
|
|
||||||
|
## Weekly summary
|
||||||
|
|
||||||
|
`computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `yesterday_avg` / `today_avg` | regional (50km) → national fallback |
|
||||||
|
| `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) |
|
||||||
|
| `yesterday_today_delta_pence` | `today − yesterday`; sign tells you which was cheaper |
|
||||||
|
| `last_7_days_series` | array of `{date, avg}`, one entry per day with data |
|
||||||
|
| `last_7_days_change_pence` | `series[last].avg − series[0].avg` |
|
||||||
|
| `cheapest_day` / `priciest_day` | min/max of the series |
|
||||||
|
| `is_regional` | `true` only if regional data was actually used; `false` after national fallback |
|
||||||
|
|
||||||
|
## API gate
|
||||||
|
|
||||||
|
The prediction is **embedded in the `/api/stations` response** under the
|
||||||
|
`prediction` key — there is no standalone prediction endpoint. The same payload
|
||||||
|
shape ships back regardless of route, but the gate runs server-side:
|
||||||
|
`PlanFeatures::for($user)->can('ai_predictions')`.
|
||||||
|
|
||||||
|
- ai_predictions allowed (plus, pro): full multi-signal payload
|
||||||
|
(`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`,
|
||||||
|
`reasoning`, `weekly_summary`, `signals`, …)
|
||||||
|
- otherwise (free, basic, guest): stripped teaser
|
||||||
|
`{fuel_type, predicted_direction, tier_locked: true}` for the upsell card
|
||||||
|
|
||||||
|
Bundling into `/api/stations` ties prediction availability to a real station
|
||||||
|
search — there is no way to scrape the prediction independently. Don't add a
|
||||||
|
separate prediction route or accept a request body without coords; the
|
||||||
|
prediction is always computed alongside a search.
|
||||||
|
|
||||||
|
## What never to do
|
||||||
|
|
||||||
|
- Don't introduce a new signal without giving it `enabled`, `confidence`, and a
|
||||||
|
weight in both national + regional weight maps.
|
||||||
|
- Don't read `brent_prices` directly from the prediction service — go through
|
||||||
|
`price_predictions`. The prediction table is the source of truth for
|
||||||
|
oil-direction-as-a-signal.
|
||||||
|
- Don't reintroduce a confidence formula that uses `|directional_score|` — that
|
||||||
|
conflates magnitude with sureness.
|
||||||
|
- Don't add a stable-direction signal to `directional_weight` — stable signals
|
||||||
|
must not dilute direction.
|
||||||
|
|
||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "app/Services/NationalFuelPredictionService.php"
|
||||||
|
- "app/Http/Controllers/Api/StationController.php"
|
||||||
|
- "tests/Unit/Services/NationalFuelPredictionServiceTest.php"
|
||||||
|
- "tests/Feature/Api/StationControllerTest.php"
|
||||||
|
---
|
||||||
@@ -105,7 +105,8 @@ Each tier has two prices:
|
|||||||
```
|
```
|
||||||
id unsignedBigInteger PK
|
id unsignedBigInteger PK
|
||||||
name string — free | basic | plus | pro
|
name string — free | basic | plus | pro
|
||||||
stripe_price_id string nullable — maps Cashier price to this plan
|
stripe_price_id_monthly string nullable — Cashier price ID for monthly billing
|
||||||
|
stripe_price_id_annual string nullable — Cashier price ID for annual billing
|
||||||
features json — see shape below
|
features json — see shape below
|
||||||
active boolean default true
|
active boolean default true
|
||||||
timestamps
|
timestamps
|
||||||
@@ -123,7 +124,8 @@ timestamps
|
|||||||
"frequency": "triggered"
|
"frequency": "triggered"
|
||||||
},
|
},
|
||||||
"push": {
|
"push": {
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"frequency": "triggered"
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -141,7 +143,20 @@ timestamps
|
|||||||
```
|
```
|
||||||
|
|
||||||
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
|
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
|
||||||
`daily`, `triggered`. All boolean features default `false` on free.
|
`daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`,
|
||||||
|
`triggered`. All boolean features default `false` on free.
|
||||||
|
|
||||||
|
`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality:
|
||||||
|
|
||||||
|
- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only).
|
||||||
|
- `ai_predictions` is **plus and pro only**.
|
||||||
|
- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`)
|
||||||
|
even when `enabled: false` — set to `0` on disabled tiers.
|
||||||
|
|
||||||
|
> Deeper per-tier feature flags (history window, prediction level, leaderboard size,
|
||||||
|
> saved stations, fuel log caps, brand comparison, route planner, family sharing) are
|
||||||
|
> defined in `docs/superpowers/specs/2026-04-15-tier-features-design.md` — that spec
|
||||||
|
> is the source of truth for entitlements beyond notification channels.
|
||||||
|
|
||||||
### `user_notification_preferences` table
|
### `user_notification_preferences` table
|
||||||
|
|
||||||
@@ -184,8 +199,8 @@ missed-count queries.
|
|||||||
|
|
||||||
- Casts `features` to `array`.
|
- Casts `features` to `array`.
|
||||||
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
||||||
active Cashier subscription price ID, matches to `stripe_price_id`, falls back
|
active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
|
||||||
to the `free` plan row.
|
or `stripe_price_id_annual`, falls back to the `free` plan row.
|
||||||
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
|
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
|
||||||
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
|
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
|
||||||
|
|
||||||
|
|||||||
30
.claude/settings.json
Normal file
30
.claude/settings.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"deny": [
|
||||||
|
"Read(./.env)",
|
||||||
|
"Read(.env)",
|
||||||
|
"Bash(cat .env)",
|
||||||
|
"Bash(cat ./.env)",
|
||||||
|
"Bash(head .env)",
|
||||||
|
"Bash(head ./.env)",
|
||||||
|
"Bash(tail .env)",
|
||||||
|
"Bash(tail ./.env)",
|
||||||
|
"Bash(less .env)",
|
||||||
|
"Bash(less ./.env)",
|
||||||
|
"Bash(more .env)",
|
||||||
|
"Bash(more ./.env)",
|
||||||
|
"Bash(grep * .env)",
|
||||||
|
"Bash(grep * ./.env)",
|
||||||
|
"Bash(rg * .env)",
|
||||||
|
"Bash(rg * ./.env)",
|
||||||
|
"Bash(awk * .env)",
|
||||||
|
"Bash(awk * ./.env)",
|
||||||
|
"Bash(php artisan migrate:fresh)",
|
||||||
|
"Bash(php artisan migrate:fresh *)",
|
||||||
|
"Bash(php artisan migrate:reset)",
|
||||||
|
"Bash(php artisan migrate:reset *)",
|
||||||
|
"Bash(php artisan db:wipe)",
|
||||||
|
"Bash(php artisan db:wipe *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
53
.env.example
53
.env.example
@@ -1,8 +1,8 @@
|
|||||||
APP_NAME=Laravel
|
APP_NAME="Fuel Alert"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://fuel-alert.test
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -20,18 +20,18 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=fuel-price
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=fuel-price
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=password
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
SESSION_ENCRYPT=false
|
SESSION_ENCRYPT=false
|
||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=.fuel-alert.test
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -64,14 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
FUELALERT_API_KEY=
|
FUEL_FINDER_CLIENT_ID=
|
||||||
|
FUEL_FINDER_CLIENT_SECRET=
|
||||||
|
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-haiku-4-5
|
||||||
|
|
||||||
FRED_API_KEY=
|
FRED_API_KEY=
|
||||||
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
|
|
||||||
|
|
||||||
STRIPE_PRICE_BASIC_MONTHLY=
|
ONESIGNAL_APP_ID=
|
||||||
STRIPE_PRICE_BASIC_ANNUAL=
|
ONESIGNAL_API_KEY=
|
||||||
STRIPE_PRICE_PLUS_MONTHLY=
|
|
||||||
STRIPE_PRICE_PLUS_ANNUAL=
|
VONAGE_KEY=
|
||||||
|
VONAGE_SECRET=
|
||||||
|
VONAGE_WHATSAPP_FROM=
|
||||||
|
VONAGE_SMS_FROM=FuelAlert
|
||||||
|
API_SECRET_KEY=
|
||||||
|
EIA_API_KEY=
|
||||||
|
|
||||||
|
LLM_PREDICTION_PROVIDER=anthropic
|
||||||
|
|
||||||
|
STRIPE_KEY=
|
||||||
|
STRIPE_SECRET=
|
||||||
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
CASHIER_CURRENCY=gbp
|
||||||
|
|
||||||
|
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
|
||||||
|
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
|
||||||
|
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
|
||||||
|
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
|
||||||
STRIPE_PRICE_PRO_MONTHLY=
|
STRIPE_PRICE_PRO_MONTHLY=
|
||||||
STRIPE_PRICE_PRO_ANNUAL=
|
STRIPE_PRICE_PRO_ANNUAL=
|
||||||
|
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=fuel-alert.test
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.DS_Store
|
||||||
/.phpunit.cache
|
/.phpunit.cache
|
||||||
/node_modules
|
/node_modules
|
||||||
/public/build
|
/public/build
|
||||||
@@ -22,3 +23,5 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
|
/.worktrees/
|
||||||
|
/ONSPD_Online_Latest_Centroids_*.csv
|
||||||
|
|||||||
17
CLAUDE.md
17
CLAUDE.md
@@ -1,8 +1,22 @@
|
|||||||
# FuelAlert — Claude Code Instructions
|
# Fuel Price — Claude Code Instructions
|
||||||
|
|
||||||
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
|
||||||
based on local price trends. Built solo by a PHP/Laravel developer.
|
based on local price trends. Built solo by a PHP/Laravel developer.
|
||||||
|
|
||||||
|
## Destructive DB operations — HARD STOP
|
||||||
|
|
||||||
|
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
|
||||||
|
|
||||||
|
- `php artisan migrate:fresh` (with any flags, including `--seed`)
|
||||||
|
- `php artisan migrate:reset`
|
||||||
|
- `php artisan db:wipe`
|
||||||
|
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
|
||||||
|
- Any sequence that effectively rebuilds the schema or drops tables
|
||||||
|
|
||||||
|
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
|
||||||
|
|
||||||
|
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
|
||||||
|
|
||||||
## Project overview
|
## Project overview
|
||||||
|
|
||||||
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
|
||||||
@@ -31,6 +45,7 @@ npm run dev # Vite asset watcher
|
|||||||
@.claude/rules/database.md
|
@.claude/rules/database.md
|
||||||
@.claude/rules/notifications.md
|
@.claude/rules/notifications.md
|
||||||
@.claude/rules/scoring.md
|
@.claude/rules/scoring.md
|
||||||
|
@.claude/rules/prediction.md
|
||||||
@.claude/rules/payments.md
|
@.claude/rules/payments.md
|
||||||
@.claude/rules/tiers.md
|
@.claude/rules/tiers.md
|
||||||
@.claude/rules/livewire.md
|
@.claude/rules/livewire.md
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
namespace App\Actions\Fortify;
|
namespace App\Actions\Fortify;
|
||||||
|
|
||||||
use App\Concerns\PasswordValidationRules;
|
use App\Concerns\PasswordValidationRules;
|
||||||
use App\Concerns\ProfileValidationRules;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||||
|
|
||||||
class CreateNewUser implements CreatesNewUsers
|
class CreateNewUser implements CreatesNewUsers
|
||||||
{
|
{
|
||||||
use PasswordValidationRules, ProfileValidationRules;
|
use PasswordValidationRules;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and create a newly registered user.
|
* Validate and create a newly registered user.
|
||||||
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
|
|||||||
public function create(array $input): User
|
public function create(array $input): User
|
||||||
{
|
{
|
||||||
Validator::make($input, [
|
Validator::make($input, [
|
||||||
...$this->profileRules(),
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
|
||||||
'password' => $this->passwordRules(),
|
'password' => $this->passwordRules(),
|
||||||
])->validate();
|
])->validate();
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,4 @@ trait PasswordValidationRules
|
|||||||
{
|
{
|
||||||
return ['required', 'string', Password::default(), 'confirmed'];
|
return ['required', 'string', Password::default(), 'confirmed'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate the current password.
|
|
||||||
*
|
|
||||||
* @return array<int, Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function currentPasswordRules(): array
|
|
||||||
{
|
|
||||||
return ['required', 'string', 'current_password'];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Concerns;
|
|
||||||
|
|
||||||
use App\Models\User;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
trait ProfileValidationRules
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user profiles.
|
|
||||||
*
|
|
||||||
* @return array<string, array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>>
|
|
||||||
*/
|
|
||||||
protected function profileRules(?int $userId = null): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => $this->nameRules(),
|
|
||||||
'email' => $this->emailRules($userId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user names.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function nameRules(): array
|
|
||||||
{
|
|
||||||
return ['required', 'string', 'max:255'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules used to validate user emails.
|
|
||||||
*
|
|
||||||
* @return array<int, \Illuminate\Contracts\Validation\Rule|array<mixed>|string>
|
|
||||||
*/
|
|
||||||
protected function emailRules(?int $userId = null): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'email',
|
|
||||||
'max:255',
|
|
||||||
$userId === null
|
|
||||||
? Rule::unique(User::class)
|
|
||||||
: Rule::unique(User::class)->ignore($userId),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
52
app/Console/Commands/ArchiveOldPricesCommand.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\StationPrice;
|
||||||
|
use App\Models\StationPriceArchive;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ArchiveOldPricesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'fuel:archive';
|
||||||
|
|
||||||
|
protected $description = 'Move station price history older than 12 months to the archive table';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subMonths(12);
|
||||||
|
|
||||||
|
$count = StationPrice::where('price_effective_at', '<', $cutoff)->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->info('No prices to archive.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Archiving {$count} price record(s) older than {$cutoff->toDateString()}...");
|
||||||
|
|
||||||
|
StationPrice::where('price_effective_at', '<', $cutoff)
|
||||||
|
->chunkById(1000, function ($prices): void {
|
||||||
|
$rows = $prices->map(fn (StationPrice $price): array => [
|
||||||
|
'station_id' => $price->station_id,
|
||||||
|
'fuel_type' => $price->fuel_type->value,
|
||||||
|
'price_pence' => $price->price_pence,
|
||||||
|
'price_effective_at' => $price->price_effective_at,
|
||||||
|
'price_reported_at' => $price->price_reported_at,
|
||||||
|
'recorded_at' => $price->recorded_at,
|
||||||
|
])->all();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($rows, $prices): void {
|
||||||
|
StationPriceArchive::insert($rows);
|
||||||
|
StationPrice::whereIn('id', $prices->pluck('id'))->delete();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->info('Archive complete.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Console/Commands/BackfillOilPrices.php
Normal file
33
app/Console/Commands/BackfillOilPrices.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\BrentPriceFetcher;
|
||||||
|
use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
|
||||||
|
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
|
||||||
|
class BackfillOilPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BrentPriceFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
$from = (string) $this->option('from');
|
||||||
|
$to = (string) ($this->option('to') ?: now()->toDateString());
|
||||||
|
|
||||||
|
$this->info("Backfilling Brent ({$from} → {$to}) from FRED...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $fetcher->backfillFromFred($from, $to);
|
||||||
|
$this->info(sprintf('Upserted %d Brent rows.', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
$this->error('FRED backfill failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
30
app/Console/Commands/EvaluateVolatilityRegime.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\VolatilityRegimeService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:evaluate-volatility')]
|
||||||
|
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
|
||||||
|
class EvaluateVolatilityRegime extends Command
|
||||||
|
{
|
||||||
|
public function handle(VolatilityRegimeService $service): int
|
||||||
|
{
|
||||||
|
$regime = $service->evaluate();
|
||||||
|
|
||||||
|
if ($regime === null) {
|
||||||
|
$this->info('Volatility regime: OFF');
|
||||||
|
} else {
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Volatility regime: ON (trigger=%s, since %s)',
|
||||||
|
$regime->trigger,
|
||||||
|
$regime->flipped_on_at->toIso8601String(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
|
|||||||
use Illuminate\Console\Attributes\Description;
|
use Illuminate\Console\Attributes\Description;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use Illuminate\Console\Attributes\Signature;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
#[Signature('oil:fetch')]
|
#[Signature('oil:fetch')]
|
||||||
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
||||||
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
|
|||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (BrentPriceFetchException $e) {
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
|
||||||
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
|
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
|
|||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
} catch (BrentPriceFetchException $e) {
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
|
||||||
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
|
|||||||
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
35
app/Console/Commands/ImportBeisFuelPrices.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\BeisImporter;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('beis:import')]
|
||||||
|
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
|
||||||
|
class ImportBeisFuelPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BeisImporter $importer): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$result = $importer->import();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('BEIS import failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Imported %d rows from %s — latest date: %s.',
|
||||||
|
$result['parsed'],
|
||||||
|
$result['csv_url'],
|
||||||
|
$result['latest_date'],
|
||||||
|
));
|
||||||
|
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Console/Commands/ImportPostcodes.php
Normal file
162
app/Console/Commands/ImportPostcodes.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
|
||||||
|
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
|
||||||
|
final class ImportPostcodes extends Command
|
||||||
|
{
|
||||||
|
private const int CHUNK_SIZE = 1000;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$file = $this->option('file');
|
||||||
|
|
||||||
|
if ($file === null || ! is_readable($file)) {
|
||||||
|
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handle = fopen($file, 'r');
|
||||||
|
|
||||||
|
if ($handle === false) {
|
||||||
|
$this->error("Unable to open {$file}.");
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$header = fgetcsv($handle);
|
||||||
|
|
||||||
|
if ($header === false) {
|
||||||
|
$this->error('CSV is empty.');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$headerCounts = array_count_values(array_map('strtolower', $header));
|
||||||
|
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
|
||||||
|
|
||||||
|
$pcdColumn = null;
|
||||||
|
|
||||||
|
foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) {
|
||||||
|
if (isset($columns[$candidate])) {
|
||||||
|
$pcdColumn = $candidate;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($pcdColumn === null) {
|
||||||
|
$this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).');
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ([$pcdColumn, 'lat', 'long'] as $required) {
|
||||||
|
if (($headerCounts[$required] ?? 0) > 1) {
|
||||||
|
$this->error("Column '{$required}' appears more than once — refusing to import.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['lat', 'long'] as $required) {
|
||||||
|
if (! isset($columns[$required])) {
|
||||||
|
$this->error("Missing required column '{$required}'.");
|
||||||
|
fclose($handle);
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasDoterm = isset($columns['doterm']);
|
||||||
|
|
||||||
|
// Stream into a staging table first. Only swap into the live
|
||||||
|
// postcodes / outcodes tables once the full CSV has been consumed —
|
||||||
|
// a mid-stream failure leaves production data untouched.
|
||||||
|
Schema::dropIfExists('postcodes_staging');
|
||||||
|
Schema::create('postcodes_staging', function (Blueprint $table): void {
|
||||||
|
$table->string('postcode', 7);
|
||||||
|
$table->string('outcode', 4);
|
||||||
|
$table->decimal('lat', 10, 7);
|
||||||
|
$table->decimal('lng', 10, 7);
|
||||||
|
});
|
||||||
|
|
||||||
|
$buffer = [];
|
||||||
|
$imported = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (($row = fgetcsv($handle)) !== false) {
|
||||||
|
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lat = trim((string) ($row[$columns['lat']] ?? ''));
|
||||||
|
$lng = trim((string) ($row[$columns['long']] ?? ''));
|
||||||
|
|
||||||
|
if ($lat === '' || $lng === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
|
||||||
|
|
||||||
|
if ($pcd === '' || strlen($pcd) < 5) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$buffer[] = [
|
||||||
|
'postcode' => $pcd,
|
||||||
|
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
|
||||||
|
'lat' => (float) $lat,
|
||||||
|
'lng' => (float) $lng,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (count($buffer) >= self::CHUNK_SIZE) {
|
||||||
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
$buffer = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($buffer !== []) {
|
||||||
|
DB::table('postcodes_staging')->insert($buffer);
|
||||||
|
$imported += count($buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap: empty live tables, copy from staging, derive outcodes.
|
||||||
|
DB::table('outcodes')->truncate();
|
||||||
|
DB::table('postcodes')->truncate();
|
||||||
|
DB::statement(
|
||||||
|
'INSERT INTO postcodes (postcode, outcode, lat, lng)
|
||||||
|
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
|
||||||
|
);
|
||||||
|
DB::statement(
|
||||||
|
'INSERT INTO outcodes (outcode, lat, lng)
|
||||||
|
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
} finally {
|
||||||
|
fclose($handle);
|
||||||
|
Schema::dropIfExists('postcodes_staging');
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Imported {$imported} postcodes.");
|
||||||
|
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,26 @@
|
|||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Events\PricesUpdatedEvent;
|
use App\Events\PricesUpdatedEvent;
|
||||||
|
use App\Models\Station;
|
||||||
use App\Services\FuelPriceService;
|
use App\Services\FuelPriceService;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PollFuelPrices extends Command
|
class PollFuelPrices extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'fuel:poll {--full : Also refresh station metadata}';
|
protected $signature = 'fuel:poll {--full : Force refresh station metadata before polling}';
|
||||||
|
|
||||||
protected $description = 'Poll the Fuel Finder API for latest prices';
|
protected $description = 'Poll the Fuel Finder API for latest prices';
|
||||||
|
|
||||||
public function handle(FuelPriceService $service): int
|
public function handle(FuelPriceService $service): int
|
||||||
{
|
{
|
||||||
$fullRefresh = (bool) $this->option('full');
|
$fullRefresh = (bool) $this->option('full');
|
||||||
|
$lastRefresh = Station::max('last_seen_at');
|
||||||
|
$stationsStale = $lastRefresh === null || Carbon::parse($lastRefresh)->isBefore(today());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($fullRefresh) {
|
if ($fullRefresh || $stationsStale) {
|
||||||
$this->info('Refreshing station metadata...');
|
$this->info('Refreshing station metadata...');
|
||||||
$service->refreshStations();
|
$service->refreshStations();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
|
||||||
|
|
||||||
use App\Services\BrentPricePredictor;
|
|
||||||
use Illuminate\Console\Command;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class PredictOilPrices extends Command
|
|
||||||
{
|
|
||||||
protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
|
|
||||||
|
|
||||||
protected $description = 'Generate a Brent crude oil price direction prediction';
|
|
||||||
|
|
||||||
public function handle(BrentPricePredictor $predictor): int
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$latest = $predictor->latestPrice();
|
|
||||||
|
|
||||||
if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
|
|
||||||
$message = sprintf(
|
|
||||||
'Prediction already generated for %s at %s.',
|
|
||||||
$latest->date->toDateString(),
|
|
||||||
$latest->prediction_generated_at->toDateTimeString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $this->confirm($message.' Run again anyway?', default: false)) {
|
|
||||||
$this->info('Skipped.');
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info('Generating prediction...');
|
|
||||||
$prediction = $predictor->generatePrediction();
|
|
||||||
|
|
||||||
if ($prediction === null) {
|
|
||||||
$this->error('Could not generate a prediction — not enough price data.');
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->info(sprintf(
|
|
||||||
'Done. [%s] direction=%s confidence=%d%% — %s',
|
|
||||||
strtoupper($prediction->source->value),
|
|
||||||
$prediction->direction->value,
|
|
||||||
$prediction->confidence,
|
|
||||||
$prediction->reasoning,
|
|
||||||
));
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
$this->error("Prediction failed: {$e->getMessage()}");
|
|
||||||
|
|
||||||
return self::FAILURE;
|
|
||||||
}
|
|
||||||
|
|
||||||
return self::SUCCESS;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
21
app/Console/Commands/ResolveForecastOutcomes.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\OutcomeResolver;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:resolve-outcomes')]
|
||||||
|
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
|
||||||
|
class ResolveForecastOutcomes extends Command
|
||||||
|
{
|
||||||
|
public function handle(OutcomeResolver $resolver): int
|
||||||
|
{
|
||||||
|
$count = $resolver->resolvePending();
|
||||||
|
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Console/Commands/RunLlmOverlay.php
Normal file
34
app/Console/Commands/RunLlmOverlay.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\LlmOverlayService;
|
||||||
|
use Illuminate\Console\Attributes\Description;
|
||||||
|
use Illuminate\Console\Attributes\Signature;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
|
||||||
|
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
|
||||||
|
class RunLlmOverlay extends Command
|
||||||
|
{
|
||||||
|
public function handle(LlmOverlayService $service): int
|
||||||
|
{
|
||||||
|
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info(sprintf(
|
||||||
|
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
|
||||||
|
$row->id,
|
||||||
|
$row->direction,
|
||||||
|
$row->confidence,
|
||||||
|
$row->major_impact_event ? 'YES' : 'no',
|
||||||
|
));
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,21 +11,20 @@ enum FuelType: string
|
|||||||
case B10 = 'b10';
|
case B10 = 'b10';
|
||||||
case Hvo = 'hvo';
|
case Hvo = 'hvo';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::E10 => 'Petrol (E10)',
|
||||||
|
self::E5 => 'Premium (E5)',
|
||||||
|
self::B7Standard => 'Diesel (B7)',
|
||||||
|
self::B7Premium => 'Prem Diesel',
|
||||||
|
self::B10 => 'Diesel (B10)',
|
||||||
|
self::Hvo => 'HVO',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public static function fromApiValue(string $value): self
|
public static function fromApiValue(string $value): self
|
||||||
{
|
{
|
||||||
return self::from(strtolower($value));
|
return self::from(strtolower($value));
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function fromAlias(string $alias): self
|
|
||||||
{
|
|
||||||
return match (strtolower($alias)) {
|
|
||||||
'diesel', 'b7_standard' => self::B7Standard,
|
|
||||||
'premium_diesel', 'b7_premium' => self::B7Premium,
|
|
||||||
'petrol', 'unleaded', 'e10' => self::E10,
|
|
||||||
'premium_unleaded', 'e5' => self::E5,
|
|
||||||
'b10' => self::B10,
|
|
||||||
'hvo' => self::Hvo,
|
|
||||||
default => throw new \ValueError("Unknown fuel type alias: {$alias}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,14 @@ enum PlanTier: string
|
|||||||
case Basic = 'basic';
|
case Basic = 'basic';
|
||||||
case Plus = 'plus';
|
case Plus = 'plus';
|
||||||
case Pro = 'pro';
|
case Pro = 'pro';
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Free => 'Free',
|
||||||
|
self::Basic => 'Daily',
|
||||||
|
self::Plus => 'Smart',
|
||||||
|
self::Pro => 'Pro',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Enums/PriceReliability.php
Normal file
45
app/Enums/PriceReliability.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
enum PriceReliability: string
|
||||||
|
{
|
||||||
|
case Reliable = 'reliable';
|
||||||
|
case Stale = 'stale';
|
||||||
|
case Outdated = 'outdated';
|
||||||
|
|
||||||
|
public static function fromUpdatedAt(?Carbon $updatedAt): self
|
||||||
|
{
|
||||||
|
if ($updatedAt === null) {
|
||||||
|
return self::Outdated;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = $updatedAt->diffInHours(now());
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$hours <= 72 => self::Reliable,
|
||||||
|
$hours <= 168 => self::Stale,
|
||||||
|
default => self::Outdated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function weight(): int
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Reliable => 0,
|
||||||
|
self::Stale => 1,
|
||||||
|
self::Outdated => 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function label(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Reliable => 'Reliable',
|
||||||
|
self::Stale => 'Older price — verify before driving',
|
||||||
|
self::Outdated => 'Outdated — may be inaccurate',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
|
|
||||||
case Data;
|
case Data;
|
||||||
|
|
||||||
|
case Forecasting;
|
||||||
|
|
||||||
case System;
|
case System;
|
||||||
|
|
||||||
public function getLabel(): string
|
public function getLabel(): string
|
||||||
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'Users',
|
self::Users => 'Users',
|
||||||
self::Data => 'Data',
|
self::Data => 'Data',
|
||||||
|
self::Forecasting => 'Forecasting',
|
||||||
self::System => 'System',
|
self::System => 'System',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
|
|||||||
return match ($this) {
|
return match ($this) {
|
||||||
self::Users => 'heroicon-o-users',
|
self::Users => 'heroicon-o-users',
|
||||||
self::Data => 'heroicon-o-circle-stack',
|
self::Data => 'heroicon-o-circle-stack',
|
||||||
|
self::Forecasting => null,
|
||||||
self::System => 'heroicon-o-cog-6-tooth',
|
self::System => 'heroicon-o-cog-6-tooth',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
62
app/Filament/Resources/Backtests/BacktestResource.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\Backtests\Pages\ListBacktests;
|
||||||
|
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
|
||||||
|
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
|
||||||
|
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class BacktestResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Backtest::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Backtests';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 3;
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return BacktestInfolist::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return BacktestsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListBacktests::route('/'),
|
||||||
|
'view' => ViewBacktest::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ListBacktests.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Backtests\BacktestResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListBacktests extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = BacktestResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
16
app/Filament/Resources/Backtests/Pages/ViewBacktest.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Backtests\BacktestResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewBacktest extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = BacktestResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Schemas;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use Filament\Infolists\Components\IconEntry;
|
||||||
|
use Filament\Infolists\Components\KeyValueEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class BacktestInfolist
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Section::make('Run')->columns(3)->schema([
|
||||||
|
TextEntry::make('model_version')->columnSpanFull(),
|
||||||
|
TextEntry::make('directional_accuracy')
|
||||||
|
->label('Accuracy')
|
||||||
|
->state(fn (Backtest $record): string => $record->directional_accuracy === null
|
||||||
|
? '—'
|
||||||
|
: round((float) $record->directional_accuracy, 1).'%'),
|
||||||
|
TextEntry::make('mae_pence')
|
||||||
|
->label('MAE')
|
||||||
|
->state(fn (Backtest $record): string => $record->mae_pence === null
|
||||||
|
? '—'
|
||||||
|
: number_format((float) $record->mae_pence, 2).'p'),
|
||||||
|
IconEntry::make('leak_suspected')
|
||||||
|
->label('Leak suspected')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('danger'),
|
||||||
|
TextEntry::make('train_start')->date('d M Y'),
|
||||||
|
TextEntry::make('train_end')->date('d M Y'),
|
||||||
|
TextEntry::make('eval_start')->date('d M Y'),
|
||||||
|
TextEntry::make('eval_end')->date('d M Y'),
|
||||||
|
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
|
||||||
|
]),
|
||||||
|
Section::make('Calibration table')
|
||||||
|
->description('Empirical hit rate per magnitude bin from the eval window.')
|
||||||
|
->schema([
|
||||||
|
KeyValueEntry::make('calibration_table')
|
||||||
|
->hiddenLabel()
|
||||||
|
->keyLabel('Magnitude bin')
|
||||||
|
->valueLabel('Empirical hit rate')
|
||||||
|
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
|
||||||
|
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
|
||||||
|
->all())
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make('Feature spec')->schema([
|
||||||
|
KeyValueEntry::make('features_json')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make('Coefficients')
|
||||||
|
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
|
||||||
|
->collapsed()
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('coefficients_json')
|
||||||
|
->hiddenLabel()
|
||||||
|
->state(fn (Backtest $record): string => json_encode(
|
||||||
|
$record->coefficients_json,
|
||||||
|
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
|
||||||
|
) ?: '')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
|
||||||
|
* into JSON strings rather than dropping them.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|null $features
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected static function flattenForKeyValue(?array $features): array
|
||||||
|
{
|
||||||
|
return collect($features ?? [])
|
||||||
|
->mapWithKeys(fn ($value, $key) => [
|
||||||
|
(string) $key => is_scalar($value)
|
||||||
|
? (string) $value
|
||||||
|
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
94
app/Filament/Resources/Backtests/Tables/BacktestsTable.php
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Backtests\Tables;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class BacktestsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('model_version')
|
||||||
|
->searchable()
|
||||||
|
->limit(32)
|
||||||
|
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
|
||||||
|
TextColumn::make('directional_accuracy')
|
||||||
|
->label('Accuracy')
|
||||||
|
->state(fn (Backtest $record): string => $record->directional_accuracy === null
|
||||||
|
? '—'
|
||||||
|
: round((float) $record->directional_accuracy, 1).'%')
|
||||||
|
->color(fn (Backtest $record) => self::accuracyColor($record))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('mae_pence')
|
||||||
|
->label('MAE')
|
||||||
|
->state(fn (Backtest $record): string => $record->mae_pence === null
|
||||||
|
? '—'
|
||||||
|
: number_format((float) $record->mae_pence, 2).'p')
|
||||||
|
->sortable(),
|
||||||
|
IconColumn::make('leak_suspected')
|
||||||
|
->label('Leak?')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('danger'),
|
||||||
|
TextColumn::make('eval_start')
|
||||||
|
->date('d M Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('eval_end')
|
||||||
|
->date('d M Y')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('ran_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
])
|
||||||
|
->defaultSort('ran_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
Filter::make('leak_suspected')
|
||||||
|
->label('Suspicious accuracy (leak suspected)')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
|
||||||
|
Filter::make('below_ship_gate')
|
||||||
|
->label('Below ship gate')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function accuracyColor(Backtest $record): ?string
|
||||||
|
{
|
||||||
|
if ($record->directional_accuracy === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accuracy = (float) $record->directional_accuracy;
|
||||||
|
|
||||||
|
if ($accuracy > 75 && $record->leak_suspected) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy < 60) {
|
||||||
|
return 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy < 62) {
|
||||||
|
return 'warning';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($accuracy <= 75) {
|
||||||
|
return 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources;
|
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Filament\NavigationGroup;
|
|
||||||
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
|
|
||||||
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use Filament\Actions\ViewAction;
|
|
||||||
use Filament\Forms\Components\DatePicker;
|
|
||||||
use Filament\Infolists\Components\TextEntry;
|
|
||||||
use Filament\Resources\Resource;
|
|
||||||
use Filament\Schemas\Components\Section;
|
|
||||||
use Filament\Schemas\Schema;
|
|
||||||
use Filament\Tables\Columns\TextColumn;
|
|
||||||
use Filament\Tables\Filters\Filter;
|
|
||||||
use Filament\Tables\Filters\SelectFilter;
|
|
||||||
use Filament\Tables\Table;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
|
|
||||||
class OilPredictionResource extends Resource
|
|
||||||
{
|
|
||||||
protected static ?string $model = PricePrediction::class;
|
|
||||||
|
|
||||||
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
|
|
||||||
|
|
||||||
protected static ?string $navigationLabel = 'Oil Predictions';
|
|
||||||
|
|
||||||
protected static ?int $navigationSort = 3;
|
|
||||||
|
|
||||||
public static function table(Table $table): Table
|
|
||||||
{
|
|
||||||
return $table
|
|
||||||
->columns([
|
|
||||||
TextColumn::make('predicted_for')
|
|
||||||
->date('d M Y')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('source')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma => 'EWMA',
|
|
||||||
})
|
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'success',
|
|
||||||
PredictionSource::LlmWithContext => 'warning',
|
|
||||||
PredictionSource::Ewma => 'info',
|
|
||||||
}),
|
|
||||||
TextColumn::make('direction')
|
|
||||||
->badge()
|
|
||||||
->color(fn (TrendDirection $state) => match ($state) {
|
|
||||||
TrendDirection::Rising => 'danger',
|
|
||||||
TrendDirection::Falling => 'success',
|
|
||||||
TrendDirection::Flat => 'gray',
|
|
||||||
}),
|
|
||||||
TextColumn::make('confidence')
|
|
||||||
->suffix('%')
|
|
||||||
->sortable(),
|
|
||||||
TextColumn::make('reasoning')
|
|
||||||
->limit(60)
|
|
||||||
->placeholder('—'),
|
|
||||||
TextColumn::make('generated_at')
|
|
||||||
->dateTime('d M Y H:i')
|
|
||||||
->sortable(),
|
|
||||||
])
|
|
||||||
->defaultSort('predicted_for', 'desc')
|
|
||||||
->filters([
|
|
||||||
SelectFilter::make('source')
|
|
||||||
->options([
|
|
||||||
PredictionSource::Llm->value => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext->value => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma->value => 'EWMA',
|
|
||||||
]),
|
|
||||||
SelectFilter::make('direction')
|
|
||||||
->options([
|
|
||||||
TrendDirection::Rising->value => 'Rising',
|
|
||||||
TrendDirection::Falling->value => 'Falling',
|
|
||||||
TrendDirection::Flat->value => 'Flat',
|
|
||||||
]),
|
|
||||||
Filter::make('predicted_for')
|
|
||||||
->schema([
|
|
||||||
DatePicker::make('from')->label('From'),
|
|
||||||
DatePicker::make('until')->label('Until'),
|
|
||||||
])
|
|
||||||
->query(function (Builder $query, array $data) {
|
|
||||||
$query
|
|
||||||
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
|
|
||||||
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
->recordActions([
|
|
||||||
ViewAction::make(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function infolist(Schema $schema): Schema
|
|
||||||
{
|
|
||||||
return $schema->components([
|
|
||||||
Section::make('Prediction')->schema([
|
|
||||||
TextEntry::make('predicted_for')->date('d M Y'),
|
|
||||||
TextEntry::make('source')
|
|
||||||
->badge()
|
|
||||||
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'LLM',
|
|
||||||
PredictionSource::LlmWithContext => 'LLM + Context',
|
|
||||||
PredictionSource::Ewma => 'EWMA',
|
|
||||||
})
|
|
||||||
->color(fn (PredictionSource $state) => match ($state) {
|
|
||||||
PredictionSource::Llm => 'success',
|
|
||||||
PredictionSource::LlmWithContext => 'warning',
|
|
||||||
PredictionSource::Ewma => 'info',
|
|
||||||
}),
|
|
||||||
TextEntry::make('direction')
|
|
||||||
->badge()
|
|
||||||
->color(fn (TrendDirection $state) => match ($state) {
|
|
||||||
TrendDirection::Rising => 'danger',
|
|
||||||
TrendDirection::Falling => 'success',
|
|
||||||
TrendDirection::Flat => 'gray',
|
|
||||||
}),
|
|
||||||
TextEntry::make('confidence')->suffix('%'),
|
|
||||||
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
|
|
||||||
])->columns(3),
|
|
||||||
Section::make('Reasoning')->schema([
|
|
||||||
TextEntry::make('reasoning')
|
|
||||||
->columnSpanFull()
|
|
||||||
->placeholder('No reasoning recorded'),
|
|
||||||
]),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'index' => ListOilPredictions::route('/'),
|
|
||||||
'view' => ViewOilPrediction::route('/{record}'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OilPredictionResource;
|
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Filament\Resources\Pages\ListRecords;
|
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
class ListOilPredictions extends ListRecords
|
|
||||||
{
|
|
||||||
protected static string $resource = OilPredictionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
Action::make('runPrediction')
|
|
||||||
->label('Run Prediction Now')
|
|
||||||
->icon('heroicon-o-cpu-chip')
|
|
||||||
->requiresConfirmation()
|
|
||||||
->modalHeading('Run oil price prediction?')
|
|
||||||
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
|
|
||||||
->action(function () {
|
|
||||||
$result = Artisan::call('oil:predict', ['--force' => true]);
|
|
||||||
|
|
||||||
if ($result === 0) {
|
|
||||||
Notification::make()
|
|
||||||
->title('Prediction generated successfully')
|
|
||||||
->success()
|
|
||||||
->send();
|
|
||||||
} else {
|
|
||||||
Notification::make()
|
|
||||||
->title('Prediction failed')
|
|
||||||
->body('Check API Logs for details.')
|
|
||||||
->danger()
|
|
||||||
->send();
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Filament\Resources\OilPredictionResource\Pages;
|
|
||||||
|
|
||||||
use App\Filament\Resources\OilPredictionResource;
|
|
||||||
use Filament\Resources\Pages\ViewRecord;
|
|
||||||
|
|
||||||
class ViewOilPrediction extends ViewRecord
|
|
||||||
{
|
|
||||||
protected static string $resource = OilPredictionResource::class;
|
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
|
||||||
{
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,7 @@ class PlanForm
|
|||||||
->components([
|
->components([
|
||||||
Section::make('Fuel Types')
|
Section::make('Fuel Types')
|
||||||
->schema([
|
->schema([
|
||||||
TextInput::make('features.fuel_types.max')
|
TextInput::make('max_fuel_types')
|
||||||
->label('Max fuel types')
|
->label('Max fuel types')
|
||||||
->helperText('Leave blank for unlimited.')
|
->helperText('Leave blank for unlimited.')
|
||||||
->numeric()
|
->numeric()
|
||||||
@@ -28,9 +28,9 @@ class PlanForm
|
|||||||
Section::make('Email')
|
Section::make('Email')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('features.email.enabled')
|
Toggle::make('email_enabled')
|
||||||
->label('Enabled'),
|
->label('Enabled'),
|
||||||
Select::make('features.email.frequency')
|
Select::make('email_frequency')
|
||||||
->label('Frequency')
|
->label('Frequency')
|
||||||
->options([
|
->options([
|
||||||
'weekly_digest' => 'Weekly digest',
|
'weekly_digest' => 'Weekly digest',
|
||||||
@@ -40,23 +40,31 @@ class PlanForm
|
|||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make('Push')
|
Section::make('Push')
|
||||||
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('features.push.enabled')
|
Toggle::make('push_enabled')
|
||||||
->label('Enabled'),
|
->label('Enabled'),
|
||||||
|
Select::make('push_frequency')
|
||||||
|
->label('Frequency')
|
||||||
|
->options([
|
||||||
|
'none' => 'None (disabled)',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'triggered' => 'Triggered',
|
||||||
|
]),
|
||||||
]),
|
]),
|
||||||
|
|
||||||
Section::make('WhatsApp')
|
Section::make('WhatsApp')
|
||||||
->columns(3)
|
->columns(3)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('features.whatsapp.enabled')
|
Toggle::make('whatsapp_enabled')
|
||||||
->label('Enabled'),
|
->label('Enabled'),
|
||||||
TextInput::make('features.whatsapp.daily_limit')
|
TextInput::make('whatsapp_daily_limit')
|
||||||
->label('Daily limit')
|
->label('Daily limit')
|
||||||
->numeric()
|
->numeric()
|
||||||
->integer()
|
->integer()
|
||||||
->minValue(0)
|
->minValue(0)
|
||||||
->required(),
|
->required(),
|
||||||
TextInput::make('features.whatsapp.scheduled_updates')
|
TextInput::make('whatsapp_scheduled_updates')
|
||||||
->label('Scheduled updates per day')
|
->label('Scheduled updates per day')
|
||||||
->numeric()
|
->numeric()
|
||||||
->integer()
|
->integer()
|
||||||
@@ -67,9 +75,9 @@ class PlanForm
|
|||||||
Section::make('SMS')
|
Section::make('SMS')
|
||||||
->columns(2)
|
->columns(2)
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('features.sms.enabled')
|
Toggle::make('sms_enabled')
|
||||||
->label('Enabled'),
|
->label('Enabled'),
|
||||||
TextInput::make('features.sms.daily_limit')
|
TextInput::make('sms_daily_limit')
|
||||||
->label('Daily limit')
|
->label('Daily limit')
|
||||||
->numeric()
|
->numeric()
|
||||||
->integer()
|
->integer()
|
||||||
@@ -79,11 +87,11 @@ class PlanForm
|
|||||||
|
|
||||||
Section::make('Features')
|
Section::make('Features')
|
||||||
->schema([
|
->schema([
|
||||||
Toggle::make('features.ai_predictions')
|
Toggle::make('ai_predictions')
|
||||||
->label('AI predictions'),
|
->label('AI predictions'),
|
||||||
Toggle::make('features.price_threshold')
|
Toggle::make('price_threshold')
|
||||||
->label('Price threshold alerts'),
|
->label('Price threshold alerts'),
|
||||||
Toggle::make('features.score_alerts')
|
Toggle::make('score_alerts')
|
||||||
->label('Score change alerts'),
|
->label('Score change alerts'),
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ class PlansTable
|
|||||||
->label('Tier')
|
->label('Tier')
|
||||||
->badge()
|
->badge()
|
||||||
->sortable(),
|
->sortable(),
|
||||||
TextColumn::make('features.email.frequency')
|
TextColumn::make('email_frequency')
|
||||||
->label('Email')
|
->label('Email')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('features.sms.daily_limit')
|
TextColumn::make('sms_daily_limit')
|
||||||
->label('SMS/day')
|
->label('SMS/day')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('features.whatsapp.daily_limit')
|
TextColumn::make('whatsapp_daily_limit')
|
||||||
->label('WhatsApp/day')
|
->label('WhatsApp/day')
|
||||||
->placeholder('—'),
|
->placeholder('—'),
|
||||||
TextColumn::make('features.fuel_types.max')
|
TextColumn::make('max_fuel_types')
|
||||||
->label('Fuel types')
|
->label('Fuel types')
|
||||||
->placeholder('Unlimited'),
|
->placeholder('Unlimited'),
|
||||||
IconColumn::make('active')
|
IconColumn::make('active')
|
||||||
|
|||||||
@@ -2,20 +2,29 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources;
|
namespace App\Filament\Resources;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Enums\PlanTier;
|
||||||
use App\Filament\NavigationGroup;
|
use App\Filament\NavigationGroup;
|
||||||
use App\Filament\Resources\UserResource\Pages\EditUser;
|
use App\Filament\Resources\UserResource\Pages\EditUser;
|
||||||
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
use App\Filament\Resources\UserResource\Pages\ListUsers;
|
||||||
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
use Filament\Actions\DeleteAction;
|
use Filament\Actions\DeleteAction;
|
||||||
use Filament\Actions\EditAction;
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Resources\Resource;
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
use Filament\Tables\Columns\IconColumn;
|
use Filament\Tables\Columns\IconColumn;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
use Filament\Tables\Filters\TernaryFilter;
|
use Filament\Tables\Filters\TernaryFilter;
|
||||||
use Filament\Tables\Table;
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class UserResource extends Resource
|
class UserResource extends Resource
|
||||||
{
|
{
|
||||||
@@ -28,12 +37,89 @@ class UserResource extends Resource
|
|||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema->components([
|
return $schema->components([
|
||||||
|
Section::make('Profile')->columns(2)->schema([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('email')
|
||||||
|
->email()
|
||||||
|
->required()
|
||||||
|
->maxLength(255),
|
||||||
|
TextInput::make('postcode')
|
||||||
|
->maxLength(8),
|
||||||
|
Select::make('preferred_fuel_type')
|
||||||
|
->label('Preferred fuel type')
|
||||||
|
->options(collect(FuelType::cases())
|
||||||
|
->mapWithKeys(fn (FuelType $t) => [$t->value => $t->label()])
|
||||||
|
->all())
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Access')->columns(2)->schema([
|
||||||
Toggle::make('is_admin')
|
Toggle::make('is_admin')
|
||||||
->label('Admin')
|
->label('Admin')
|
||||||
->helperText('Grants access to this admin panel.'),
|
->helperText('Grants access to this admin panel.'),
|
||||||
TextInput::make('postcode')
|
DateTimePicker::make('email_verified_at')
|
||||||
->label('Postcode')
|
->label('Email verified at'),
|
||||||
->maxLength(8),
|
]),
|
||||||
|
|
||||||
|
Section::make('Subscription')->columns(2)->schema([
|
||||||
|
Select::make('tier')
|
||||||
|
->label('Tier')
|
||||||
|
->options([
|
||||||
|
PlanTier::Free->value => 'Free',
|
||||||
|
PlanTier::Basic->value => 'Basic',
|
||||||
|
PlanTier::Plus->value => 'Plus',
|
||||||
|
PlanTier::Pro->value => 'Pro',
|
||||||
|
])
|
||||||
|
->required()
|
||||||
|
->live()
|
||||||
|
->dehydrated(false)
|
||||||
|
->afterStateHydrated(fn (Select $component, ?User $record) => $component
|
||||||
|
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
|
||||||
|
Select::make('cadence')
|
||||||
|
->label('Billing Cadence')
|
||||||
|
->options([
|
||||||
|
'monthly' => 'Monthly',
|
||||||
|
'annual' => 'Annual',
|
||||||
|
])
|
||||||
|
->default('monthly')
|
||||||
|
->dehydrated(false)
|
||||||
|
->visible(fn (callable $get): bool => ($get('tier') ?? '') !== PlanTier::Free->value)
|
||||||
|
->helperText('Only applies when assigning a paid tier. Real Stripe subscriptions are not modified.'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Security')->columns(2)->schema([
|
||||||
|
DateTimePicker::make('two_factor_confirmed_at')
|
||||||
|
->label('2FA confirmed at')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('password')
|
||||||
|
->label('Set new password')
|
||||||
|
->password()
|
||||||
|
->revealable()
|
||||||
|
->minLength(8)
|
||||||
|
->dehydrated(fn (?string $state): bool => filled($state))
|
||||||
|
->dehydrateStateUsing(fn (string $state): string => bcrypt($state))
|
||||||
|
->helperText('Leave blank to keep current password.')
|
||||||
|
->afterStateHydrated(fn (TextInput $component) => $component->state(null)),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Billing')->columns(3)->schema([
|
||||||
|
TextInput::make('stripe_id')
|
||||||
|
->label('Stripe customer ID')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('pm_type')
|
||||||
|
->label('Payment method')
|
||||||
|
->disabled(),
|
||||||
|
TextInput::make('pm_last_four')
|
||||||
|
->label('Card last 4')
|
||||||
|
->disabled(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Timestamps')->columns(2)->schema([
|
||||||
|
DateTimePicker::make('created_at')->disabled(),
|
||||||
|
DateTimePicker::make('updated_at')->disabled(),
|
||||||
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +130,16 @@ class UserResource extends Resource
|
|||||||
TextColumn::make('name')->searchable()->sortable(),
|
TextColumn::make('name')->searchable()->sortable(),
|
||||||
TextColumn::make('email')->searchable()->sortable(),
|
TextColumn::make('email')->searchable()->sortable(),
|
||||||
TextColumn::make('postcode')->placeholder('—'),
|
TextColumn::make('postcode')->placeholder('—'),
|
||||||
|
TextColumn::make('tier')
|
||||||
|
->label('Tier')
|
||||||
|
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
|
||||||
|
->badge()
|
||||||
|
->colors([
|
||||||
|
'gray' => 'free',
|
||||||
|
'primary' => 'basic',
|
||||||
|
'warning' => 'plus',
|
||||||
|
'success' => 'pro',
|
||||||
|
]),
|
||||||
IconColumn::make('is_admin')
|
IconColumn::make('is_admin')
|
||||||
->label('Admin')
|
->label('Admin')
|
||||||
->boolean(),
|
->boolean(),
|
||||||
@@ -62,6 +158,60 @@ class UserResource extends Resource
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel any existing admin-granted subscription, then (if a paid tier
|
||||||
|
* was requested) insert a fresh synthetic active subscription row.
|
||||||
|
*/
|
||||||
|
public static function applyTier(User $user, string $tier, string $cadence): void
|
||||||
|
{
|
||||||
|
$hasRealStripeSubscription = $user->subscriptions()
|
||||||
|
->where('stripe_id', 'not like', 'admin_%')
|
||||||
|
->whereIn('stripe_status', ['active', 'trialing', 'past_due'])
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($hasRealStripeSubscription) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"User {$user->email} has an active Stripe subscription — modify it through the Stripe dashboard, not the admin panel."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->subscriptions()->where('stripe_id', 'like', 'admin_%')->delete();
|
||||||
|
|
||||||
|
if ($tier === PlanTier::Free->value) {
|
||||||
|
self::bustPlanCache($user);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
|
||||||
|
|
||||||
|
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
|
||||||
|
$plan = Plan::where('name', $tier)->first();
|
||||||
|
|
||||||
|
if ($plan && empty($plan->{$planColumn})) {
|
||||||
|
$plan->update([$planColumn => $priceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->subscriptions()->create([
|
||||||
|
'type' => 'default',
|
||||||
|
'stripe_id' => 'admin_'.Str::uuid(),
|
||||||
|
'stripe_status' => 'active',
|
||||||
|
'stripe_price' => $priceId,
|
||||||
|
'quantity' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
self::bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function bustPlanCache(User $user): void
|
||||||
|
{
|
||||||
|
if (Cache::supportsTags()) {
|
||||||
|
Cache::tags(['plans'])->flush();
|
||||||
|
} else {
|
||||||
|
Cache::forget("plan_for_user_{$user->id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,14 +3,52 @@
|
|||||||
namespace App\Filament\Resources\UserResource\Pages;
|
namespace App\Filament\Resources\UserResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\UserResource;
|
use App\Filament\Resources\UserResource;
|
||||||
|
use App\Filament\Resources\UserResource\Widgets\MissedNotificationsOverview;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\EditRecord;
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
class EditUser extends EditRecord
|
class EditUser extends EditRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = UserResource::class;
|
protected static string $resource = UserResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderWidgets(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
MissedNotificationsOverview::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeaderWidgetsColumns(): int|array
|
||||||
|
{
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->record;
|
||||||
|
|
||||||
|
$tier = $this->data['tier'] ?? null;
|
||||||
|
$cadence = $this->data['cadence'] ?? 'monthly';
|
||||||
|
|
||||||
|
if ($tier === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
UserResource::applyTier($user, $tier, $cadence);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Tier not changed')
|
||||||
|
->body($e->getMessage())
|
||||||
|
->warning()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\UserResource\Widgets;
|
||||||
|
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget;
|
||||||
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
|
|
||||||
|
class MissedNotificationsOverview extends StatsOverviewWidget
|
||||||
|
{
|
||||||
|
public ?User $record = null;
|
||||||
|
|
||||||
|
protected function getStats(): array
|
||||||
|
{
|
||||||
|
if ($this->record === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$userId = $this->record->id;
|
||||||
|
|
||||||
|
$missedTodayByChannel = fn (string $channel): int => NotificationLog::where('user_id', $userId)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$missedThisMonth = NotificationLog::where('user_id', $userId)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
Stat::make('SMS missed today', $missedTodayByChannel('sms'))
|
||||||
|
->color($missedTodayByChannel('sms') > 0 ? 'warning' : 'gray'),
|
||||||
|
Stat::make('WhatsApp missed today', $missedTodayByChannel('whatsapp'))
|
||||||
|
->color($missedTodayByChannel('whatsapp') > 0 ? 'warning' : 'gray'),
|
||||||
|
Stat::make('Total missed this month', $missedThisMonth)
|
||||||
|
->color($missedThisMonth > 0 ? 'warning' : 'gray'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateWatchedEvent extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditWatchedEvent extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWatchedEvents extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WatchedEventResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\DateTimePicker;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class WatchedEventForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
TextInput::make('label')
|
||||||
|
->required()
|
||||||
|
->maxLength(128)
|
||||||
|
->helperText('Short geopolitical event label, e.g. "Iran tensions Apr–May 2026".'),
|
||||||
|
DateTimePicker::make('starts_at')
|
||||||
|
->label('Starts at')
|
||||||
|
->required(),
|
||||||
|
DateTimePicker::make('ends_at')
|
||||||
|
->label('Ends at')
|
||||||
|
->required()
|
||||||
|
->after('starts_at'),
|
||||||
|
Textarea::make('notes')
|
||||||
|
->maxLength(2000)
|
||||||
|
->rows(4)
|
||||||
|
->columnSpanFull(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents\Tables;
|
||||||
|
|
||||||
|
use App\Models\WatchedEvent;
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class WatchedEventsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('label')
|
||||||
|
->searchable()
|
||||||
|
->sortable()
|
||||||
|
->limit(60)
|
||||||
|
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
|
||||||
|
TextColumn::make('starts_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('ends_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label('Status')
|
||||||
|
->badge()
|
||||||
|
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
|
||||||
|
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
|
||||||
|
TextColumn::make('notes')
|
||||||
|
->limit(50)
|
||||||
|
->placeholder('—')
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('starts_at', 'desc')
|
||||||
|
->filters([
|
||||||
|
Filter::make('currently_active')
|
||||||
|
->label('Currently active')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query
|
||||||
|
->where('starts_at', '<=', now())
|
||||||
|
->where('ends_at', '>=', now())),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
DeleteAction::make(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function isActive(WatchedEvent $record): bool
|
||||||
|
{
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
return $record->starts_at !== null
|
||||||
|
&& $record->ends_at !== null
|
||||||
|
&& $record->starts_at->lessThanOrEqualTo($now)
|
||||||
|
&& $record->ends_at->greaterThanOrEqualTo($now);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WatchedEvents;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
|
||||||
|
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
|
||||||
|
use App\Models\WatchedEvent;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class WatchedEventResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = WatchedEvent::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Watched Events';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 1;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return WatchedEventForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return WatchedEventsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListWatchedEvents::route('/'),
|
||||||
|
'create' => CreateWatchedEvent::route('/create'),
|
||||||
|
'edit' => EditWatchedEvent::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListWeeklyForecasts extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = WeeklyForecastResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewWeeklyForecast extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = WeeklyForecastResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
|
||||||
|
|
||||||
|
use App\Models\WeeklyForecast;
|
||||||
|
use Filament\Infolists\Components\IconEntry;
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class WeeklyForecastInfolist
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema->components([
|
||||||
|
Section::make('Forecast')->columns(3)->schema([
|
||||||
|
TextEntry::make('forecast_for')->date('d M Y'),
|
||||||
|
TextEntry::make('direction')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state) => match ($state) {
|
||||||
|
'rising' => 'warning',
|
||||||
|
'falling' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextEntry::make('magnitude_pence')
|
||||||
|
->label('Magnitude')
|
||||||
|
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)),
|
||||||
|
TextEntry::make('ridge_confidence')
|
||||||
|
->label('Confidence')
|
||||||
|
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
|
||||||
|
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null),
|
||||||
|
IconEntry::make('flagged_duty_change')
|
||||||
|
->label('Duty change adjacent')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('warning'),
|
||||||
|
TextEntry::make('generated_at')->dateTime('d M Y H:i'),
|
||||||
|
]),
|
||||||
|
Section::make('Reasoning')->schema([
|
||||||
|
TextEntry::make('reasoning')
|
||||||
|
->columnSpanFull()
|
||||||
|
->placeholder('No reasoning recorded.'),
|
||||||
|
]),
|
||||||
|
Section::make('Model')
|
||||||
|
->description('Calibration table from the matching backtest determines the displayed confidence.')
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('model_version')->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function formatMagnitude(?int $magnitudePence): string
|
||||||
|
{
|
||||||
|
if ($magnitudePence === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pence = round($magnitudePence / 100, 1);
|
||||||
|
$sign = $pence > 0 ? '+' : '';
|
||||||
|
|
||||||
|
return $sign.$pence.'p';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts\Tables;
|
||||||
|
|
||||||
|
use App\Models\WeeklyForecast;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\Filter;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class WeeklyForecastsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('forecast_for')
|
||||||
|
->label('Forecast for')
|
||||||
|
->date('d M Y')
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('direction')
|
||||||
|
->badge()
|
||||||
|
->color(fn (string $state) => match ($state) {
|
||||||
|
'rising' => 'warning',
|
||||||
|
'falling' => 'success',
|
||||||
|
default => 'gray',
|
||||||
|
}),
|
||||||
|
TextColumn::make('magnitude_pence')
|
||||||
|
->label('Magnitude')
|
||||||
|
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence))
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('ridge_confidence')
|
||||||
|
->label('Confidence')
|
||||||
|
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
|
||||||
|
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null)
|
||||||
|
->sortable(),
|
||||||
|
IconColumn::make('flagged_duty_change')
|
||||||
|
->label('Duty change')
|
||||||
|
->boolean()
|
||||||
|
->trueColor('warning'),
|
||||||
|
TextColumn::make('model_version')
|
||||||
|
->searchable()
|
||||||
|
->limit(32)
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('generated_at')
|
||||||
|
->dateTime('d M Y H:i')
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->defaultSort('forecast_for', 'desc')
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('direction')
|
||||||
|
->multiple()
|
||||||
|
->options([
|
||||||
|
'rising' => 'Rising',
|
||||||
|
'falling' => 'Falling',
|
||||||
|
'flat' => 'Flat',
|
||||||
|
]),
|
||||||
|
Filter::make('high_confidence')
|
||||||
|
->label('High confidence')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)),
|
||||||
|
Filter::make('flagged_duty_change')
|
||||||
|
->label('Duty-change-adjacent')
|
||||||
|
->toggle()
|
||||||
|
->query(fn (Builder $query) => $query->where('flagged_duty_change', true)),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function formatMagnitude(?int $magnitudePence): string
|
||||||
|
{
|
||||||
|
if ($magnitudePence === null) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
$pence = round($magnitudePence / 100, 1);
|
||||||
|
$sign = $pence > 0 ? '+' : '';
|
||||||
|
|
||||||
|
return $sign.$pence.'p';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\WeeklyForecasts;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
|
||||||
|
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
|
||||||
|
use App\Models\WeeklyForecast;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class WeeklyForecastResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = WeeklyForecast::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Weekly Forecasts';
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 2;
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return WeeklyForecastInfolist::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return WeeklyForecastsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canEdit(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canDelete(Model $record): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListWeeklyForecasts::route('/'),
|
||||||
|
'view' => ViewWeeklyForecast::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@
|
|||||||
namespace App\Filament\Widgets;
|
namespace App\Filament\Widgets;
|
||||||
|
|
||||||
use App\Models\ApiLog;
|
use App\Models\ApiLog;
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Models\Search;
|
use App\Models\Search;
|
||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\WeeklyForecast;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||||
@@ -21,7 +21,7 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
$this->usersStat(),
|
$this->usersStat(),
|
||||||
$this->searchesStat(),
|
$this->searchesStat(),
|
||||||
$this->stationsStat(),
|
$this->stationsStat(),
|
||||||
$this->oilPredictionStat(),
|
$this->weeklyForecastStat(),
|
||||||
$this->apiErrorsStat(),
|
$this->apiErrorsStat(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget
|
|||||||
->color('success');
|
->color('success');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function oilPredictionStat(): Stat
|
private function weeklyForecastStat(): Stat
|
||||||
{
|
{
|
||||||
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first();
|
$forecast = WeeklyForecast::query()->latest('generated_at')->first();
|
||||||
|
|
||||||
if ($prediction === null) {
|
if ($forecast === null) {
|
||||||
return Stat::make('Latest oil prediction', 'None')
|
return Stat::make('Latest weekly forecast', 'None')
|
||||||
->icon('heroicon-o-beaker')
|
->icon('heroicon-o-beaker')
|
||||||
->color('gray');
|
->color('gray');
|
||||||
}
|
}
|
||||||
|
|
||||||
$ageHours = $prediction->generated_at->diffInHours(now());
|
$ageHours = $forecast->generated_at->diffInHours(now());
|
||||||
$color = $ageHours > 24 ? 'warning' : 'success';
|
$color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week
|
||||||
$value = $prediction->direction->label().' · '.$prediction->confidence.'%';
|
$directionLabel = ucfirst($forecast->direction);
|
||||||
|
$value = $directionLabel.' · '.$forecast->ridge_confidence.'%';
|
||||||
|
|
||||||
return Stat::make('Latest oil prediction', $value)
|
return Stat::make('Latest weekly forecast', $value)
|
||||||
->description('Generated '.$prediction->generated_at->diffForHumans())
|
->description('For week of '.$forecast->forecast_for->toDateString())
|
||||||
->url(route('filament.admin.resources.oil-predictions.index'))
|
|
||||||
->icon('heroicon-o-beaker')
|
->icon('heroicon-o-beaker')
|
||||||
->color($color);
|
->color($color);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Plan;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -61,6 +63,25 @@ class AuthController extends Controller
|
|||||||
|
|
||||||
public function me(Request $request): JsonResponse
|
public function me(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json($request->user());
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return new JsonResponse('null', json: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = $user->subscription();
|
||||||
|
|
||||||
|
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'name' => $user->name,
|
||||||
|
'email' => $user->email,
|
||||||
|
'two_factor_confirmed_at' => $user->two_factor_confirmed_at?->toIso8601String(),
|
||||||
|
'tier' => PlanFeatures::for($user)->tier(),
|
||||||
|
'subscription_cancelled' => $subscription?->canceled() ?? false,
|
||||||
|
'subscription_cadence' => Plan::resolveCadenceForUser($user),
|
||||||
|
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
|
||||||
|
'subscription_expires_at' => $expiresAt?->toIso8601String(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Http\Requests\Api\PredictionRequest;
|
|
||||||
use App\Services\NationalFuelPredictionService;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class PredictionController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly NationalFuelPredictionService $predictionService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(PredictionRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$lat = $request->filled('lat') ? (float) $request->input('lat') : null;
|
|
||||||
$lng = $request->filled('lng') ? (float) $request->input('lng') : null;
|
|
||||||
|
|
||||||
$result = $this->predictionService->predict($lat, $lng);
|
|
||||||
|
|
||||||
return response()->json($result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,23 +2,60 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
use App\Enums\PriceClassification;
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Api\NearbyStationsRequest;
|
use App\Http\Requests\Api\NearbyStationsRequest;
|
||||||
use App\Http\Resources\Api\StationResource;
|
use App\Http\Resources\Api\StationResource;
|
||||||
use App\Models\Search;
|
|
||||||
use App\Models\Station;
|
|
||||||
use App\Services\PostcodeService;
|
use App\Services\PostcodeService;
|
||||||
use Illuminate\Database\Query\JoinClause;
|
use App\Services\StationSearch\SearchCriteria;
|
||||||
|
use App\Services\StationSearch\StationSearchService;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StationController extends Controller
|
class StationController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(private readonly PostcodeService $postcodeService) {}
|
public function __construct(
|
||||||
|
private readonly PostcodeService $postcodeService,
|
||||||
|
private readonly StationSearchService $searchService,
|
||||||
|
) {}
|
||||||
|
|
||||||
public function index(NearbyStationsRequest $request): JsonResponse
|
public function index(NearbyStationsRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
[$lat, $lng] = $this->resolveCoordinates($request);
|
||||||
|
|
||||||
|
$criteria = new SearchCriteria(
|
||||||
|
lat: $lat,
|
||||||
|
lng: $lng,
|
||||||
|
fuelType: $request->fuelType(),
|
||||||
|
radiusKm: $request->radius(),
|
||||||
|
sort: $request->sort(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->searchService->search(
|
||||||
|
$criteria,
|
||||||
|
$request->user(),
|
||||||
|
hash('sha256', $request->ip() ?? ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => StationResource::collection($result->stations),
|
||||||
|
'meta' => [
|
||||||
|
'count' => $result->stations->count(),
|
||||||
|
'fuel_type' => $criteria->fuelType->value,
|
||||||
|
'radius_km' => $criteria->radiusKm,
|
||||||
|
'lat' => $criteria->lat,
|
||||||
|
'lng' => $criteria->lng,
|
||||||
|
'lowest_pence' => $result->pricesSummary['lowest'],
|
||||||
|
'highest_pence' => $result->pricesSummary['highest'],
|
||||||
|
'cheapest_price_pence' => $result->pricesSummary['lowest'],
|
||||||
|
'avg_pence' => $result->pricesSummary['avg'],
|
||||||
|
'reliability_counts' => $result->reliabilityCounts,
|
||||||
|
],
|
||||||
|
'prediction' => $result->prediction,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: float, 1: float} */
|
||||||
|
private function resolveCoordinates(NearbyStationsRequest $request): array
|
||||||
{
|
{
|
||||||
if ($request->filled('postcode')) {
|
if ($request->filled('postcode')) {
|
||||||
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
|
||||||
@@ -27,76 +64,9 @@ class StationController extends Controller
|
|||||||
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$lat = $location->lat;
|
return [$location->lat, $location->lng];
|
||||||
$lng = $location->lng;
|
|
||||||
} else {
|
|
||||||
$lat = (float) $request->input('lat');
|
|
||||||
$lng = (float) $request->input('lng');
|
|
||||||
}
|
}
|
||||||
$fuelType = $request->fuelType();
|
|
||||||
$radius = $request->radius();
|
|
||||||
$sort = $request->sort();
|
|
||||||
|
|
||||||
$all = Station::query()
|
return [(float) $request->input('lat'), (float) $request->input('lng')];
|
||||||
->selectRaw(
|
|
||||||
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
|
|
||||||
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
|
|
||||||
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
|
|
||||||
+ sin(radians(?)) * sin(radians(lat))
|
|
||||||
)))) AS distance_km',
|
|
||||||
[$lat, $lng, $lat],
|
|
||||||
)
|
|
||||||
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
|
|
||||||
$join->on('stations.node_id', '=', 'spc.station_id')
|
|
||||||
->where('spc.fuel_type', '=', $fuelType->value);
|
|
||||||
})
|
|
||||||
->where('stations.temporary_closure', false)
|
|
||||||
->where('stations.permanent_closure', false)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
|
|
||||||
|
|
||||||
$stations = $sort === 'reliable'
|
|
||||||
? $filtered->sortBy([
|
|
||||||
fn ($s) => PriceClassification::fromUpdatedAt(
|
|
||||||
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
|
|
||||||
)->weight(),
|
|
||||||
fn ($s) => (int) $s->price_pence,
|
|
||||||
])->values()
|
|
||||||
: $filtered->sortBy(match ($sort) {
|
|
||||||
'price' => fn ($s) => (int) $s->price_pence,
|
|
||||||
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
|
|
||||||
'brand' => fn ($s) => strtolower((string) $s->brand_name),
|
|
||||||
default => fn ($s) => (float) $s->distance_km,
|
|
||||||
})->values();
|
|
||||||
|
|
||||||
$prices = $stations->pluck('price_pence');
|
|
||||||
|
|
||||||
Search::create([
|
|
||||||
'lat_bucket' => round($lat, 2),
|
|
||||||
'lng_bucket' => round($lng, 2),
|
|
||||||
'fuel_type' => $fuelType->value,
|
|
||||||
'results_count' => $stations->count(),
|
|
||||||
'lowest_pence' => $prices->min(),
|
|
||||||
'highest_pence' => $prices->max(),
|
|
||||||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
|
||||||
'searched_at' => now(),
|
|
||||||
'ip_hash' => hash('sha256', $request->ip() ?? ''),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => StationResource::collection($stations),
|
|
||||||
'meta' => [
|
|
||||||
'count' => $stations->count(),
|
|
||||||
'fuel_type' => $fuelType->value,
|
|
||||||
'radius_km' => $radius,
|
|
||||||
'lat' => $lat,
|
|
||||||
'lng' => $lng,
|
|
||||||
'lowest_pence' => $prices->min(),
|
|
||||||
'highest_pence' => $prices->max(),
|
|
||||||
'cheapest_price_pence' => $prices->min(),
|
|
||||||
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,33 @@ namespace App\Http\Controllers\Api;
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Search;
|
use App\Models\Search;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\StationPrice;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class StatsController extends Controller
|
class StatsController extends Controller
|
||||||
{
|
{
|
||||||
|
public function live(): JsonResponse
|
||||||
|
{
|
||||||
|
$payload = Cache::remember('api:stats:live', now()->addMinutes(5), function (): array {
|
||||||
|
$stationCount = Station::query()
|
||||||
|
->where('permanent_closure', false)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$latestPriceAt = StationPrice::query()->max('recorded_at');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'station_count' => $stationCount,
|
||||||
|
'latest_price_at' => $latestPriceAt ? CarbonImmutable::parse($latestPriceAt)->toIso8601String() : null,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($payload);
|
||||||
|
}
|
||||||
|
|
||||||
public function searches(Request $request): JsonResponse
|
public function searches(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$period = $request->input('period', 'week');
|
$period = $request->input('period', 'week');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -25,7 +26,7 @@ final class UserController extends Controller
|
|||||||
public function updatePreferences(Request $request): JsonResponse
|
public function updatePreferences(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'preferred_fuel_type' => ['sometimes', Rule::in(['petrol', 'diesel', 'e5', 'b7_premium', 'b10', 'hvo'])],
|
'preferred_fuel_type' => ['sometimes', Rule::in(array_column(FuelType::cases(), 'value'))],
|
||||||
'postcode' => ['sometimes', 'string', 'max:8'],
|
'postcode' => ['sometimes', 'string', 'max:8'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
49
app/Http/Controllers/BillingController.php
Normal file
49
app/Http/Controllers/BillingController.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\PlanTier;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Cashier\Checkout;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class BillingController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Redirect the user to a Stripe Checkout session for the requested plan + cadence.
|
||||||
|
*/
|
||||||
|
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
|
||||||
|
{
|
||||||
|
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
|
||||||
|
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);
|
||||||
|
|
||||||
|
$priceId = config("services.stripe.prices.{$tier}.{$cadence}");
|
||||||
|
|
||||||
|
abort_if(empty($priceId), 404, "No Stripe price configured for {$tier} {$cadence}");
|
||||||
|
|
||||||
|
return $request->user()
|
||||||
|
->newSubscription('default', $priceId)
|
||||||
|
->allowPromotionCodes()
|
||||||
|
->checkout([
|
||||||
|
'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}',
|
||||||
|
'cancel_url' => route('billing.cancel'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Redirect the user to the Stripe-hosted Customer Billing Portal. */
|
||||||
|
public function portal(Request $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
return $request->user()->redirectToBillingPortal(route('dashboard'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function success(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('dashboard')->with('status', 'subscription_started');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(): RedirectResponse
|
||||||
|
{
|
||||||
|
return redirect()->route('dashboard')->with('status', 'subscription_cancelled');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ class NearbyStationsRequest extends FormRequest
|
|||||||
|
|
||||||
public function fuelType(): FuelType
|
public function fuelType(): FuelType
|
||||||
{
|
{
|
||||||
return FuelType::fromAlias($this->string('fuel_type')->toString());
|
return FuelType::from(strtolower($this->string('fuel_type')->toString()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function radius(): float
|
public function radius(): float
|
||||||
@@ -37,6 +37,6 @@ class NearbyStationsRequest extends FormRequest
|
|||||||
|
|
||||||
public function sort(): string
|
public function sort(): string
|
||||||
{
|
{
|
||||||
return $this->input('sort', 'price');
|
return $this->input('sort', 'reliable');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Requests\Api;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class PredictionRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
|
||||||
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Resources\Api;
|
namespace App\Http\Resources\Api;
|
||||||
|
|
||||||
use App\Enums\PriceClassification;
|
use App\Enums\PriceClassification;
|
||||||
|
use App\Enums\PriceReliability;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
@@ -11,28 +12,82 @@ class StationResource extends JsonResource
|
|||||||
{
|
{
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
// The controller pre-computes _updated_at / _reliability / _classification
|
||||||
|
// per row. Falling back to fresh computation keeps the resource usable
|
||||||
|
// outside that path (e.g. tests or future callers).
|
||||||
|
$updatedAt = $this->_updated_at
|
||||||
|
?? ($this->price_effective_at ? Carbon::parse($this->price_effective_at) : null);
|
||||||
|
$reliability = $this->_reliability ?? PriceReliability::fromUpdatedAt($updatedAt);
|
||||||
|
$classification = $this->_classification ?? PriceClassification::fromUpdatedAt($updatedAt);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'station_id' => $this->node_id,
|
'station_id' => $this->node_id,
|
||||||
'name' => $this->trading_name,
|
'name' => $this->trading_name,
|
||||||
'brand' => $this->brand_name,
|
'brand' => $this->brand_name,
|
||||||
'is_supermarket' => (bool) $this->is_supermarket,
|
'is_supermarket' => (bool) $this->is_supermarket,
|
||||||
|
'is_motorway' => (bool) $this->is_motorway_service_station,
|
||||||
'address' => implode(', ', array_filter([$this->address_line_1, $this->city])),
|
'address' => implode(', ', array_filter([$this->address_line_1, $this->city])),
|
||||||
'postcode' => $this->postcode,
|
'postcode' => $this->postcode,
|
||||||
'lat' => (float) $this->lat,
|
'lat' => (float) $this->lat,
|
||||||
'lng' => (float) $this->lng,
|
'lng' => (float) $this->lng,
|
||||||
'distance_km' => round((float) $this->distance_km, 2),
|
'distance_km' => round((float) $this->distance_km, 2),
|
||||||
'fuel_type' => $this->fuel_type,
|
'fuel_type' => $this->fuel_type,
|
||||||
|
'fuel_types_available' => $this->fuel_types ?? [],
|
||||||
|
'amenities' => $this->amenities ?? [],
|
||||||
|
'open_today' => $this->openTodayPayload(),
|
||||||
'price_pence' => (int) $this->price_pence,
|
'price_pence' => (int) $this->price_pence,
|
||||||
'price' => round((int) $this->price_pence / 100, 2),
|
'price' => round((int) $this->price_pence / 100, 2),
|
||||||
'price_updated_at' => $this->price_effective_at
|
'price_updated_at' => $updatedAt?->toISOString(),
|
||||||
? Carbon::parse($this->price_effective_at)->toISOString()
|
'price_classification' => $classification->value,
|
||||||
: null,
|
'price_classification_label' => $classification->label(),
|
||||||
'price_classification' => PriceClassification::fromUpdatedAt(
|
'reliability' => $reliability->value,
|
||||||
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
|
'reliability_label' => $reliability->label(),
|
||||||
)->value,
|
|
||||||
'price_classification_label' => PriceClassification::fromUpdatedAt(
|
|
||||||
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
|
|
||||||
)->label(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{is_24_hours: bool, open: ?string, close: ?string, is_open_now: bool}|null
|
||||||
|
*/
|
||||||
|
private function openTodayPayload(): ?array
|
||||||
|
{
|
||||||
|
$times = $this->opening_times;
|
||||||
|
|
||||||
|
if (! is_array($times) || empty($times['usual_days'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = Carbon::now('Europe/London');
|
||||||
|
$dayKey = strtolower($now->format('l'));
|
||||||
|
$today = $times['usual_days'][$dayKey] ?? null;
|
||||||
|
|
||||||
|
if (! is_array($today)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$is24 = (bool) ($today['is_24_hours'] ?? false);
|
||||||
|
$open = $today['open'] ?? null;
|
||||||
|
$close = $today['close'] ?? null;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'is_24_hours' => $is24,
|
||||||
|
'open' => $open ? substr($open, 0, 5) : null,
|
||||||
|
'close' => $close ? substr($close, 0, 5) : null,
|
||||||
|
'is_open_now' => $this->computeIsOpenNow($is24, $open, $close, $now),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeIsOpenNow(bool $is24, ?string $open, ?string $close, Carbon $now): bool
|
||||||
|
{
|
||||||
|
if ($is24) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $open || ! $close) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$current = $now->format('H:i:s');
|
||||||
|
|
||||||
|
return $current >= $open && $current < $close;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ namespace App\Jobs;
|
|||||||
use App\Models\NotificationLog;
|
use App\Models\NotificationLog;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserNotificationPreference;
|
use App\Models\UserNotificationPreference;
|
||||||
|
use App\Notifications\FuelPriceAlert;
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves allowed notification channels for a user and trigger, sends
|
* Resolves allowed notification channels for a user and trigger, dispatches
|
||||||
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
|
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
|
||||||
*
|
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
|
||||||
* Actual sending is stubbed until FuelPriceAlert notification class exists.
|
|
||||||
*/
|
*/
|
||||||
final class DispatchUserNotificationJob implements ShouldQueue
|
final class DispatchUserNotificationJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
|
|||||||
// Step 3: channels that pass tier + user-pref + daily-limit checks
|
// Step 3: channels that pass tier + user-pref + daily-limit checks
|
||||||
$allowed = $features->channelsFor($this->triggerType);
|
$allowed = $features->channelsFor($this->triggerType);
|
||||||
|
|
||||||
// Step 4: send and log sent notifications
|
// Step 4: dispatch the multi-channel notification — Laravel fans out
|
||||||
|
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
|
||||||
|
if ($allowed !== []) {
|
||||||
|
$this->user->notify(new FuelPriceAlert(
|
||||||
|
$this->triggerType,
|
||||||
|
$this->fuelType,
|
||||||
|
$this->price,
|
||||||
|
$allowed,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: log a sent entry per allowed channel. The notify() call
|
||||||
|
// above queues per-channel sends; per-channel HTTP outcomes are
|
||||||
|
// captured in api_logs by the channel adapters themselves.
|
||||||
foreach ($allowed as $channel) {
|
foreach ($allowed as $channel) {
|
||||||
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
|
|
||||||
$this->log($channel, sent: true);
|
$this->log($channel, sent: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,26 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\PricesUpdatedEvent;
|
||||||
|
use App\Services\FuelPriceService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Background full station refresh + price poll, dispatched from the admin
|
||||||
|
* "Trigger Full Poll" button. Mirrors the `fuel:poll --full` command but
|
||||||
|
* calls the service directly so typed exceptions surface to the queue's
|
||||||
|
* failed-job handler instead of being swallowed by Artisan output buffering.
|
||||||
|
*/
|
||||||
class PollFuelPricesJob implements ShouldQueue
|
class PollFuelPricesJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Queueable;
|
use Queueable;
|
||||||
|
|
||||||
public function handle(): void
|
public function handle(FuelPriceService $service): void
|
||||||
{
|
{
|
||||||
Artisan::call('fuel:poll', ['--full' => true]);
|
$service->refreshStations();
|
||||||
|
$inserted = $service->pollPrices();
|
||||||
|
|
||||||
|
PricesUpdatedEvent::dispatch($inserted, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
app/Jobs/SendPaymentFailedReminderJob.php
Normal file
44
app/Jobs/SendPaymentFailedReminderJob.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Mail\PaymentFailedDay3Reminder;
|
||||||
|
use App\Mail\PaymentFailedDay5Reminder;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class SendPaymentFailedReminderJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly int $userId,
|
||||||
|
public readonly int $day,
|
||||||
|
) {
|
||||||
|
$this->onQueue('notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$user = User::find($this->userId);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($user->grace_period_until === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailable = match ($this->day) {
|
||||||
|
3 => new PaymentFailedDay3Reminder($user),
|
||||||
|
5 => new PaymentFailedDay5Reminder($user),
|
||||||
|
default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
Mail::to($user->email)->send($mailable);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\Plan;
|
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Models\UserNotificationPreference;
|
use App\Models\UserNotificationPreference;
|
||||||
use App\Services\PlanFeatures;
|
use App\Services\PlanFeatures;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Foundation\Queue\Queueable;
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fan-out job for scheduled WhatsApp updates (morning / evening).
|
* Fan-out job for scheduled WhatsApp updates (morning / evening).
|
||||||
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
|
* Dispatches one DispatchUserNotificationJob per eligible user so each
|
||||||
|
* user's send is its own queueable unit (independent retry, no shared
|
||||||
|
* failure mode across the cohort).
|
||||||
*
|
*
|
||||||
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
|
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
|
||||||
*/
|
*/
|
||||||
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
||||||
|
|
||||||
// Plans that allow scheduled WhatsApp updates
|
// Candidates: users who have explicitly opted in to WhatsApp.
|
||||||
$eligiblePlanNames = Plan::where('active', true)
|
// Per-user tier + daily-limit + scheduled-updates checks happen via
|
||||||
->get()
|
// canSendNow('whatsapp'); that single call covers tier eligibility
|
||||||
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
|
// (canUseChannel) AND today's notification_log count.
|
||||||
->pluck('name')
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if (empty($eligiblePlanNames)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Users who have whatsapp preference enabled
|
|
||||||
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
||||||
->where('enabled', true)
|
->where('enabled', true)
|
||||||
->distinct()
|
->distinct()
|
||||||
->pluck('user_id');
|
->pluck('user_id');
|
||||||
|
|
||||||
User::whereIn('id', $userIds)
|
User::whereIn('id', $userIds)
|
||||||
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
|
->chunkById(500, function (Collection $users) use ($triggerType): void {
|
||||||
$features = PlanFeatures::for($user);
|
foreach ($users as $user) {
|
||||||
|
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
|
||||||
// Skip if their tier isn't eligible or daily limit is hit
|
continue;
|
||||||
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $features->canSendNow('whatsapp')) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
app/Listeners/HandleStripeWebhook.php
Normal file
136
app/Listeners/HandleStripeWebhook.php
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Jobs\SendPaymentFailedReminderJob;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserNotificationPreference;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Laravel\Cashier\Events\WebhookReceived;
|
||||||
|
|
||||||
|
final class HandleStripeWebhook
|
||||||
|
{
|
||||||
|
public function handle(WebhookReceived $event): void
|
||||||
|
{
|
||||||
|
$type = $event->payload['type'] ?? null;
|
||||||
|
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
|
||||||
|
|
||||||
|
if ($stripeCustomerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = User::where('stripe_id', $stripeCustomerId)->first();
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ($type) {
|
||||||
|
'customer.subscription.created',
|
||||||
|
'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
|
||||||
|
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
|
||||||
|
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
|
||||||
|
'invoice.payment_failed' => $this->handlePaymentFailed($user),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $stripeSubscription
|
||||||
|
*/
|
||||||
|
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
|
||||||
|
{
|
||||||
|
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||||
|
$this->bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $stripeSubscription
|
||||||
|
*/
|
||||||
|
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
|
||||||
|
{
|
||||||
|
UserNotificationPreference::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->whereIn('channel', ['whatsapp', 'sms'])
|
||||||
|
->update(['enabled' => false]);
|
||||||
|
|
||||||
|
$user->forceFill(['grace_period_until' => null])->save();
|
||||||
|
|
||||||
|
$this->syncPeriodFromStripePayload($stripeSubscription);
|
||||||
|
$this->bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror current_period_start / current_period_end from a Stripe subscription
|
||||||
|
* payload onto our local row so we don't depend on Stripe at read time.
|
||||||
|
*
|
||||||
|
* Stripe API ≤ 2024-11-19 places the period fields at the root of the
|
||||||
|
* subscription; later versions move them to items.data[0]. We accept either.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stripeSubscription
|
||||||
|
*/
|
||||||
|
private function syncPeriodFromStripePayload(array $stripeSubscription): void
|
||||||
|
{
|
||||||
|
$stripeId = $stripeSubscription['id'] ?? null;
|
||||||
|
|
||||||
|
if ($stripeId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription = Subscription::where('stripe_id', $stripeId)->first();
|
||||||
|
|
||||||
|
if ($subscription === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$start = $stripeSubscription['current_period_start']
|
||||||
|
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
|
||||||
|
|
||||||
|
$end = $stripeSubscription['current_period_end']
|
||||||
|
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
|
||||||
|
|
||||||
|
$subscription->stripe_data = $stripeSubscription;
|
||||||
|
|
||||||
|
if ($start !== null) {
|
||||||
|
$subscription->current_period_start = Carbon::createFromTimestamp($start);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($end !== null) {
|
||||||
|
$subscription->current_period_end = Carbon::createFromTimestamp($end);
|
||||||
|
}
|
||||||
|
|
||||||
|
$subscription->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handlePaymentSucceeded(User $user): void
|
||||||
|
{
|
||||||
|
$user->forceFill(['grace_period_until' => null])->save();
|
||||||
|
$this->bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function handlePaymentFailed(User $user): void
|
||||||
|
{
|
||||||
|
// Idempotency: only the first failed-payment event in a grace window
|
||||||
|
// transitions state + dispatches reminders. Stripe may fire this event
|
||||||
|
// multiple times per billing cycle (once per failed retry attempt).
|
||||||
|
if ($user->grace_period_until !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save();
|
||||||
|
|
||||||
|
SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3));
|
||||||
|
SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5));
|
||||||
|
|
||||||
|
$this->bustPlanCache($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bustPlanCache(User $user): void
|
||||||
|
{
|
||||||
|
$tag = Cache::tags(['plans']);
|
||||||
|
$tag->forget("plan_for_user_{$user->id}");
|
||||||
|
$tag->forget("plan_cadence_for_user_{$user->id}");
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Mail/PaymentFailedDay3Reminder.php
Normal file
33
app/Mail/PaymentFailedDay3Reminder.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
final class PaymentFailedDay3Reminder extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(public readonly User $user) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Heads up — your FuelAlert payment is retrying',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
markdown: 'emails.payment-failed-day-3',
|
||||||
|
with: [
|
||||||
|
'name' => $this->user->name,
|
||||||
|
'tier' => PlanFeatures::for($this->user)->tier(),
|
||||||
|
'portalUrl' => route('billing.portal'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Mail/PaymentFailedDay5Reminder.php
Normal file
35
app/Mail/PaymentFailedDay5Reminder.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
final class PaymentFailedDay5Reminder extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(public readonly User $user) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
$tier = PlanFeatures::for($this->user)->tier();
|
||||||
|
|
||||||
|
return new Envelope(
|
||||||
|
subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
markdown: 'emails.payment-failed-day-5',
|
||||||
|
with: [
|
||||||
|
'name' => $this->user->name,
|
||||||
|
'tier' => PlanFeatures::for($this->user)->tier(),
|
||||||
|
'portalUrl' => route('billing.portal'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])]
|
#[Fillable([
|
||||||
|
'service',
|
||||||
|
'method',
|
||||||
|
'url',
|
||||||
|
'status_code',
|
||||||
|
'duration_ms',
|
||||||
|
'error',
|
||||||
|
'response_body',
|
||||||
|
'input_tokens',
|
||||||
|
'output_tokens',
|
||||||
|
'cache_read_tokens',
|
||||||
|
'cache_write_tokens',
|
||||||
|
'ratelimit_remaining',
|
||||||
|
'ratelimit_reset_at',
|
||||||
|
])]
|
||||||
class ApiLog extends Model
|
class ApiLog extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<ApiLogFactory> */
|
/** @use HasFactory<ApiLogFactory> */
|
||||||
@@ -19,6 +33,7 @@ class ApiLog extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'created_at' => 'datetime',
|
'created_at' => 'datetime',
|
||||||
|
'ratelimit_reset_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/Models/Backtest.php
Normal file
45
app/Models/Backtest.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\BacktestFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'model_version',
|
||||||
|
'features_json',
|
||||||
|
'coefficients_json',
|
||||||
|
'train_start',
|
||||||
|
'train_end',
|
||||||
|
'eval_start',
|
||||||
|
'eval_end',
|
||||||
|
'directional_accuracy',
|
||||||
|
'mae_pence',
|
||||||
|
'calibration_table',
|
||||||
|
'leak_suspected',
|
||||||
|
'ran_at',
|
||||||
|
])]
|
||||||
|
class Backtest extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<BacktestFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'features_json' => 'array',
|
||||||
|
'coefficients_json' => 'array',
|
||||||
|
'calibration_table' => 'array',
|
||||||
|
'train_start' => 'date',
|
||||||
|
'train_end' => 'date',
|
||||||
|
'eval_start' => 'date',
|
||||||
|
'eval_end' => 'date',
|
||||||
|
'directional_accuracy' => 'decimal:2',
|
||||||
|
'mae_pence' => 'decimal:2',
|
||||||
|
'leak_suspected' => 'boolean',
|
||||||
|
'ran_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/ForecastOutcome.php
Normal file
36
app/Models/ForecastOutcome.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'forecast_for',
|
||||||
|
'model_version',
|
||||||
|
'predicted_class',
|
||||||
|
'actual_class',
|
||||||
|
'correct',
|
||||||
|
'abs_error_pence',
|
||||||
|
'resolved_at',
|
||||||
|
])]
|
||||||
|
class ForecastOutcome extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'forecast_for';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'forecast_for' => 'date',
|
||||||
|
'correct' => 'boolean',
|
||||||
|
'abs_error_pence' => 'integer',
|
||||||
|
'resolved_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/LlmOverlay.php
Normal file
35
app/Models/LlmOverlay.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'ran_at',
|
||||||
|
'forecast_for_week',
|
||||||
|
'direction',
|
||||||
|
'confidence',
|
||||||
|
'reasoning',
|
||||||
|
'events_json',
|
||||||
|
'agrees_with_ridge',
|
||||||
|
'major_impact_event',
|
||||||
|
'volatility_flag_on',
|
||||||
|
'search_used',
|
||||||
|
])]
|
||||||
|
class LlmOverlay extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'ran_at' => 'datetime',
|
||||||
|
'forecast_for_week' => 'date',
|
||||||
|
'confidence' => 'integer',
|
||||||
|
'events_json' => 'array',
|
||||||
|
'agrees_with_ridge' => 'boolean',
|
||||||
|
'major_impact_event' => 'boolean',
|
||||||
|
'volatility_flag_on' => 'boolean',
|
||||||
|
'search_used' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Models/Outcode.php
Normal file
26
app/Models/Outcode.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['outcode', 'lat', 'lng'])]
|
||||||
|
class Outcode extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'outcode';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => 'float',
|
||||||
|
'lng' => 'float',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,21 @@ class Plan extends Model
|
|||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'stripe_price_id',
|
'stripe_price_id_monthly',
|
||||||
'features',
|
'stripe_price_id_annual',
|
||||||
|
'max_fuel_types',
|
||||||
|
'email_enabled',
|
||||||
|
'email_frequency',
|
||||||
|
'push_enabled',
|
||||||
|
'push_frequency',
|
||||||
|
'whatsapp_enabled',
|
||||||
|
'whatsapp_daily_limit',
|
||||||
|
'whatsapp_scheduled_updates',
|
||||||
|
'sms_enabled',
|
||||||
|
'sms_daily_limit',
|
||||||
|
'ai_predictions',
|
||||||
|
'price_threshold',
|
||||||
|
'score_alerts',
|
||||||
'active',
|
'active',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -28,10 +41,10 @@ class Plan extends Model
|
|||||||
{
|
{
|
||||||
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||||
|
|
||||||
return $cache->remember(
|
$planId = $cache->remember(
|
||||||
"plan_for_user_{$user->id}",
|
"plan_for_user_{$user->id}",
|
||||||
3600,
|
3600,
|
||||||
function () use ($user): self {
|
function () use ($user): ?int {
|
||||||
$priceId = null;
|
$priceId = null;
|
||||||
|
|
||||||
if (method_exists($user, 'subscriptions')) {
|
if (method_exists($user, 'subscriptions')) {
|
||||||
@@ -40,14 +53,63 @@ class Plan extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($priceId) {
|
if ($priceId) {
|
||||||
$plan = static::where('stripe_price_id', $priceId)->where('active', true)->first();
|
$plan = static::where(fn ($q) => $q
|
||||||
|
->where('stripe_price_id_monthly', $priceId)
|
||||||
|
->orWhere('stripe_price_id_annual', $priceId))
|
||||||
|
->where('active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($plan) {
|
if ($plan) {
|
||||||
return $plan;
|
return $plan->id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return static::where('name', PlanTier::Free->value)->firstOrFail();
|
return static::where('name', PlanTier::Free->value)->value('id');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return static::findOrFail($planId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active subscription cadence for a user.
|
||||||
|
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
|
||||||
|
*/
|
||||||
|
public static function resolveCadenceForUser(User $user): ?string
|
||||||
|
{
|
||||||
|
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||||
|
|
||||||
|
return $cache->remember(
|
||||||
|
"plan_cadence_for_user_{$user->id}",
|
||||||
|
3600,
|
||||||
|
function () use ($user): ?string {
|
||||||
|
if (! method_exists($user, 'subscriptions')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$priceId = $user->subscriptions()->active()->value('stripe_price');
|
||||||
|
|
||||||
|
if ($priceId === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan = static::where('stripe_price_id_monthly', $priceId)
|
||||||
|
->orWhere('stripe_price_id_annual', $priceId)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($plan === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($plan->stripe_price_id_monthly === $priceId) {
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($plan->stripe_price_id_annual === $priceId) {
|
||||||
|
return 'annual';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -64,8 +126,26 @@ class Plan extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'features' => 'array',
|
'max_fuel_types' => 'integer',
|
||||||
|
'email_enabled' => 'boolean',
|
||||||
|
'push_enabled' => 'boolean',
|
||||||
|
'whatsapp_enabled' => 'boolean',
|
||||||
|
'whatsapp_daily_limit' => 'integer',
|
||||||
|
'whatsapp_scheduled_updates' => 'integer',
|
||||||
|
'sms_enabled' => 'boolean',
|
||||||
|
'sms_daily_limit' => 'integer',
|
||||||
|
'ai_predictions' => 'boolean',
|
||||||
|
'price_threshold' => 'boolean',
|
||||||
|
'score_alerts' => 'boolean',
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing display label for this plan (e.g. basic → "Daily"). */
|
||||||
|
public function displayName(): string
|
||||||
|
{
|
||||||
|
$tier = PlanTier::tryFrom((string) $this->name) ?? PlanTier::Free;
|
||||||
|
|
||||||
|
return $tier->label();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/Models/Postcode.php
Normal file
26
app/Models/Postcode.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
|
||||||
|
class Postcode extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $primaryKey = 'postcode';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'lat' => 'float',
|
||||||
|
'lng' => 'float',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Models;
|
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use Database\Factories\PricePredictionFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
|
||||||
class PricePrediction extends Model
|
|
||||||
{
|
|
||||||
/** @use HasFactory<PricePredictionFactory> */
|
|
||||||
use HasFactory;
|
|
||||||
|
|
||||||
public $timestamps = false;
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'predicted_for' => 'date',
|
|
||||||
'source' => PredictionSource::class,
|
|
||||||
'direction' => TrendDirection::class,
|
|
||||||
'confidence' => 'integer',
|
|
||||||
'generated_at' => 'datetime',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Order by source quality: llm_with_context → llm → ewma.
|
|
||||||
* Use this whenever reading the "best" prediction for a given date.
|
|
||||||
*
|
|
||||||
* @param Builder<PricePrediction> $query
|
|
||||||
* @return Builder<PricePrediction>
|
|
||||||
*/
|
|
||||||
public function scopeBestFirst(Builder $query): Builder
|
|
||||||
{
|
|
||||||
$priority = implode(', ', array_map(
|
|
||||||
fn (string $v) => "'$v'",
|
|
||||||
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
|
||||||
));
|
|
||||||
|
|
||||||
return $query->orderByRaw("FIELD(source, $priority)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
#[Fillable(['station_id', 'fuel_type', 'price_pence', 'price_effective_at', 'price_reported_at', 'recorded_at'])]
|
||||||
class StationPriceArchive extends Model
|
class StationPriceArchive extends Model
|
||||||
{
|
{
|
||||||
|
protected $table = 'station_prices_archive';
|
||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
|
|||||||
|
|
||||||
public $timestamps = false;
|
public $timestamps = false;
|
||||||
|
|
||||||
protected $primaryKey = null;
|
protected $primaryKey = 'station_id';
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
public $incrementing = false;
|
public $incrementing = false;
|
||||||
|
|
||||||
|
|||||||
17
app/Models/Subscription.php
Normal file
17
app/Models/Subscription.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Laravel\Cashier\Subscription as CashierSubscription;
|
||||||
|
|
||||||
|
class Subscription extends CashierSubscription
|
||||||
|
{
|
||||||
|
protected $casts = [
|
||||||
|
'ends_at' => 'datetime',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'trial_ends_at' => 'datetime',
|
||||||
|
'current_period_start' => 'datetime',
|
||||||
|
'current_period_end' => 'datetime',
|
||||||
|
'stripe_data' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -13,15 +13,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Laravel\Cashier\Billable;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])]
|
#[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])]
|
||||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||||
class User extends Authenticatable implements FilamentUser
|
class User extends Authenticatable implements FilamentUser
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
use Billable, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the attributes that should be cast.
|
* Get the attributes that should be cast.
|
||||||
@@ -34,6 +35,7 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'is_admin' => 'boolean',
|
'is_admin' => 'boolean',
|
||||||
|
'grace_period_until' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
use Database\Factories\UserNotificationPreferenceFactory;
|
use Database\Factories\UserNotificationPreferenceFactory;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'enabled' => 'boolean',
|
'enabled' => 'boolean',
|
||||||
|
'fuel_type' => FuelType::class,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
app/Models/VolatilityRegime.php
Normal file
30
app/Models/VolatilityRegime.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'flipped_on_at',
|
||||||
|
'flipped_off_at',
|
||||||
|
'trigger',
|
||||||
|
'trigger_detail',
|
||||||
|
'active',
|
||||||
|
])]
|
||||||
|
class VolatilityRegime extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'flipped_on_at' => 'datetime',
|
||||||
|
'flipped_off_at' => 'datetime',
|
||||||
|
'active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function currentlyActive(): ?self
|
||||||
|
{
|
||||||
|
return static::query()->where('active', true)->orderByDesc('flipped_on_at')->first();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/WatchedEvent.php
Normal file
28
app/Models/WatchedEvent.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\WatchedEventFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'label',
|
||||||
|
'starts_at',
|
||||||
|
'ends_at',
|
||||||
|
'notes',
|
||||||
|
])]
|
||||||
|
class WatchedEvent extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<WatchedEventFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'starts_at' => 'datetime',
|
||||||
|
'ends_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/WeeklyForecast.php
Normal file
35
app/Models/WeeklyForecast.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\WeeklyForecastFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'forecast_for',
|
||||||
|
'model_version',
|
||||||
|
'direction',
|
||||||
|
'magnitude_pence',
|
||||||
|
'ridge_confidence',
|
||||||
|
'flagged_duty_change',
|
||||||
|
'reasoning',
|
||||||
|
'generated_at',
|
||||||
|
])]
|
||||||
|
class WeeklyForecast extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<WeeklyForecastFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'forecast_for' => 'date',
|
||||||
|
'magnitude_pence' => 'integer',
|
||||||
|
'ridge_confidence' => 'integer',
|
||||||
|
'flagged_duty_change' => 'boolean',
|
||||||
|
'generated_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/Notifications/Channels/OneSignalChannel.php
Normal file
70
app/Notifications/Channels/OneSignalChannel.php
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Channels;
|
||||||
|
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends push notifications via the OneSignal REST API.
|
||||||
|
*
|
||||||
|
* Notifications targeting this channel must implement `toOneSignal($notifiable)`
|
||||||
|
* returning ['heading' => string, 'message' => string] (or `null` to skip).
|
||||||
|
*
|
||||||
|
* No-ops when ONESIGNAL_APP_ID/API_KEY are unset, when the notifiable user has
|
||||||
|
* no `push_token`, or when toOneSignal() returns null. Each call is logged to
|
||||||
|
* api_logs through ApiLogger.
|
||||||
|
*/
|
||||||
|
final class OneSignalChannel
|
||||||
|
{
|
||||||
|
public const string NAME = 'onesignal';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiLogger $apiLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(mixed $notifiable, Notification $notification): void
|
||||||
|
{
|
||||||
|
$appId = config('services.onesignal.app_id');
|
||||||
|
$apiKey = config('services.onesignal.api_key');
|
||||||
|
|
||||||
|
if ($appId === null || $apiKey === null) {
|
||||||
|
Log::info('OneSignalChannel: skipped — credentials not configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$playerId = $notifiable->push_token ?? null;
|
||||||
|
|
||||||
|
if ($playerId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = method_exists($notification, 'toOneSignal')
|
||||||
|
? $notification->toOneSignal($notifiable)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($payload === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://api.onesignal.com/notifications';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||||
|
->withToken($apiKey)
|
||||||
|
->acceptJson()
|
||||||
|
->post($url, [
|
||||||
|
'app_id' => $appId,
|
||||||
|
'include_player_ids' => [$playerId],
|
||||||
|
'headings' => ['en' => $payload['heading'] ?? 'Fuel Alert'],
|
||||||
|
'contents' => ['en' => $payload['message'] ?? ''],
|
||||||
|
]));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('OneSignalChannel: send failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
app/Notifications/Channels/VonageSmsChannel.php
Normal file
71
app/Notifications/Channels/VonageSmsChannel.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Channels;
|
||||||
|
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends SMS messages via the Vonage SMS API (raw HTTP — no SDK).
|
||||||
|
*
|
||||||
|
* Notifications targeting this channel must implement `toVonageSms($notifiable)`
|
||||||
|
* returning a string body (or `null` to skip).
|
||||||
|
*
|
||||||
|
* No-ops when VONAGE_KEY/SECRET are unset or when the notifiable user has no
|
||||||
|
* phone number on `whatsapp_number` (the same verified column doubles as SMS
|
||||||
|
* destination).
|
||||||
|
*/
|
||||||
|
final class VonageSmsChannel
|
||||||
|
{
|
||||||
|
public const string NAME = 'vonage-sms';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiLogger $apiLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(mixed $notifiable, Notification $notification): void
|
||||||
|
{
|
||||||
|
$key = config('services.vonage.key');
|
||||||
|
$secret = config('services.vonage.secret');
|
||||||
|
$from = config('services.vonage.sms_from', 'FuelAlert');
|
||||||
|
|
||||||
|
if ($key === null || $secret === null) {
|
||||||
|
Log::info('VonageSmsChannel: skipped — credentials not configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = $notifiable->whatsapp_number ?? null;
|
||||||
|
|
||||||
|
if ($to === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = method_exists($notification, 'toVonageSms')
|
||||||
|
? $notification->toVonageSms($notifiable)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($body === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://rest.nexmo.com/sms/json';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||||
|
->asForm()
|
||||||
|
->post($url, [
|
||||||
|
'api_key' => $key,
|
||||||
|
'api_secret' => $secret,
|
||||||
|
'from' => $from,
|
||||||
|
'to' => ltrim($to, '+'),
|
||||||
|
'text' => $body,
|
||||||
|
]));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('VonageSmsChannel: send failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal file
73
app/Notifications/Channels/VonageWhatsAppChannel.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications\Channels;
|
||||||
|
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends WhatsApp messages via the Vonage Messages API (raw HTTP — no SDK).
|
||||||
|
*
|
||||||
|
* Notifications targeting this channel must implement `toVonageWhatsApp($notifiable)`
|
||||||
|
* returning a string body (or `null` to skip).
|
||||||
|
*
|
||||||
|
* No-ops when VONAGE_KEY/SECRET/whatsapp_from are unset, when the user is not
|
||||||
|
* verified (no whatsapp_verified_at), when whatsapp_number is missing, or when
|
||||||
|
* the notification returns null.
|
||||||
|
*/
|
||||||
|
final class VonageWhatsAppChannel
|
||||||
|
{
|
||||||
|
public const string NAME = 'vonage-whatsapp';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ApiLogger $apiLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function send(mixed $notifiable, Notification $notification): void
|
||||||
|
{
|
||||||
|
$key = config('services.vonage.key');
|
||||||
|
$secret = config('services.vonage.secret');
|
||||||
|
$from = config('services.vonage.whatsapp_from');
|
||||||
|
|
||||||
|
if ($key === null || $secret === null || $from === null) {
|
||||||
|
Log::info('VonageWhatsAppChannel: skipped — credentials not configured');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$to = $notifiable->whatsapp_number ?? null;
|
||||||
|
$verified = $notifiable->whatsapp_verified_at ?? null;
|
||||||
|
|
||||||
|
if ($to === null || $verified === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = method_exists($notification, 'toVonageWhatsApp')
|
||||||
|
? $notification->toVonageWhatsApp($notifiable)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($body === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://api.nexmo.com/v1/messages';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->apiLogger->send(self::NAME, 'POST', $url, fn () => Http::timeout(10)
|
||||||
|
->withBasicAuth($key, $secret)
|
||||||
|
->acceptJson()
|
||||||
|
->post($url, [
|
||||||
|
'message_type' => 'text',
|
||||||
|
'channel' => 'whatsapp',
|
||||||
|
'from' => $from,
|
||||||
|
'to' => $to,
|
||||||
|
'text' => $body,
|
||||||
|
]));
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error('VonageWhatsAppChannel: send failed', ['error' => $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
116
app/Notifications/FuelPriceAlert.php
Normal file
116
app/Notifications/FuelPriceAlert.php
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Notifications;
|
||||||
|
|
||||||
|
use App\Notifications\Channels\OneSignalChannel;
|
||||||
|
use App\Notifications\Channels\VonageSmsChannel;
|
||||||
|
use App\Notifications\Channels\VonageWhatsAppChannel;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-channel fuel price alert. The dispatching job already filters channels
|
||||||
|
* by tier, user preference, and daily limit — `via()` returns exactly that
|
||||||
|
* filtered set. The notification is queued so individual channel sends don't
|
||||||
|
* block the dispatch job.
|
||||||
|
*
|
||||||
|
* Channel keys map to:
|
||||||
|
* 'email' → mail (Laravel built-in)
|
||||||
|
* 'push' → OneSignalChannel
|
||||||
|
* 'whatsapp' → VonageWhatsAppChannel
|
||||||
|
* 'sms' → VonageSmsChannel
|
||||||
|
*/
|
||||||
|
final class FuelPriceAlert extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/** @var array<string, class-string> */
|
||||||
|
private const array CHANNEL_MAP = [
|
||||||
|
'email' => 'mail',
|
||||||
|
'push' => OneSignalChannel::class,
|
||||||
|
'whatsapp' => VonageWhatsAppChannel::class,
|
||||||
|
'sms' => VonageSmsChannel::class,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param string[] $channels Pre-filtered channel keys ('email', 'push', 'whatsapp', 'sms') */
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $triggerType,
|
||||||
|
public readonly string $fuelType,
|
||||||
|
public readonly ?float $price,
|
||||||
|
public readonly array $channels,
|
||||||
|
) {
|
||||||
|
$this->onQueue('notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
public function via(mixed $notifiable): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (string $key) => self::CHANNEL_MAP[$key] ?? $key,
|
||||||
|
$this->channels,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toMail(mixed $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
return (new MailMessage)
|
||||||
|
->subject($this->headline())
|
||||||
|
->greeting("Hi {$notifiable->name},")
|
||||||
|
->line($this->body())
|
||||||
|
->action('Open FuelAlert', route('dashboard'))
|
||||||
|
->line('You can change which alerts you receive in your account settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{heading: string, message: string} */
|
||||||
|
public function toOneSignal(mixed $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'heading' => $this->headline(),
|
||||||
|
'message' => $this->body(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toVonageWhatsApp(mixed $notifiable): string
|
||||||
|
{
|
||||||
|
return $this->shortBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toVonageSms(mixed $notifiable): string
|
||||||
|
{
|
||||||
|
return $this->shortBody();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function headline(): string
|
||||||
|
{
|
||||||
|
return match ($this->triggerType) {
|
||||||
|
'price_threshold' => 'Price hit your threshold',
|
||||||
|
'score_change' => 'Fill-up signal changed',
|
||||||
|
'scheduled_morning' => 'Morning fuel update',
|
||||||
|
'scheduled_evening' => 'Evening fuel update',
|
||||||
|
default => 'Fuel alert',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function body(): string
|
||||||
|
{
|
||||||
|
$fuel = strtoupper($this->fuelType);
|
||||||
|
$price = $this->price !== null ? number_format($this->price, 1).'p' : null;
|
||||||
|
|
||||||
|
return match ($this->triggerType) {
|
||||||
|
'price_threshold' => $price !== null
|
||||||
|
? "{$fuel} dropped to {$price} near you."
|
||||||
|
: "{$fuel} hit your alert threshold.",
|
||||||
|
'score_change' => "The {$fuel} fill-up score has changed near you.",
|
||||||
|
'scheduled_morning', 'scheduled_evening' => "Latest {$fuel} update is ready in your dashboard.",
|
||||||
|
default => "There's a new {$fuel} alert for you.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SMS/WhatsApp must stay short — single line, ~160 chars max. */
|
||||||
|
private function shortBody(): string
|
||||||
|
{
|
||||||
|
return $this->headline().': '.$this->body();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Services\ApiLogger;
|
use App\Listeners\HandleStripeWebhook;
|
||||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
use App\Models\Subscription;
|
||||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
|
||||||
use App\Services\LlmPrediction\OilPredictionProvider;
|
|
||||||
use App\Services\LlmPrediction\OpenAiPredictionProvider;
|
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\Date;
|
use Illuminate\Support\Facades\Date;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Event;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
|
use Laravel\Cashier\Events\WebhookReceived;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -20,15 +20,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->bind(OilPredictionProvider::class, function ($app) {
|
// No bindings here. The legacy LLM prediction provider binding
|
||||||
$logger = $app->make(ApiLogger::class);
|
// was removed when the Phase 4 ridge model + Phase 8
|
||||||
|
// LlmOverlayService replaced the old daily oil prediction.
|
||||||
return match (config('services.llm.provider')) {
|
|
||||||
'openai' => new OpenAiPredictionProvider($logger),
|
|
||||||
'gemini' => new GeminiPredictionProvider($logger),
|
|
||||||
default => new AnthropicPredictionProvider($logger),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +31,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$this->configureDefaults();
|
$this->configureDefaults();
|
||||||
|
|
||||||
|
Cashier::useSubscriptionModel(Subscription::class);
|
||||||
|
|
||||||
|
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,13 +48,6 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
app()->isProduction(),
|
app()->isProduction(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
|
|
||||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
|
||||||
$pdo = DB::connection()->getPdo();
|
|
||||||
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
|
|
||||||
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||||
? Password::min(12)
|
? Password::min(12)
|
||||||
->mixedCase()
|
->mixedCase()
|
||||||
|
|||||||
@@ -3,17 +3,29 @@
|
|||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
use App\Models\ApiLog;
|
use App\Models\ApiLog;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
use Illuminate\Http\Client\Response;
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ApiLogger
|
class ApiLogger
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* Cap the stored response body. MEDIUMTEXT can hold ~16MB, but
|
||||||
|
* persisting more than 64KB is rarely useful for debugging and
|
||||||
|
* blows up the row size on busy services.
|
||||||
|
*/
|
||||||
|
private const int RESPONSE_BODY_CAP = 65_536;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute an HTTP request and log it to api_logs.
|
* Execute an HTTP request and log it to api_logs.
|
||||||
*
|
*
|
||||||
* The callable must return an Illuminate\Http\Client\Response.
|
* The callable must return an Illuminate\Http\Client\Response.
|
||||||
* Exceptions are logged and re-thrown so the caller handles them.
|
* Exceptions are logged and re-thrown so the caller handles them.
|
||||||
*
|
*
|
||||||
|
* Persists the response body to `api_logs.response_body` ONLY when
|
||||||
|
* the call failed (non-2xx) or threw. Truncates to RESPONSE_BODY_CAP.
|
||||||
|
*
|
||||||
* @param callable(): Response $request
|
* @param callable(): Response $request
|
||||||
*/
|
*/
|
||||||
public function send(string $service, string $method, string $url, callable $request): Response
|
public function send(string $service, string $method, string $url, callable $request): Response
|
||||||
@@ -21,15 +33,31 @@ class ApiLogger
|
|||||||
$start = microtime(true);
|
$start = microtime(true);
|
||||||
$statusCode = null;
|
$statusCode = null;
|
||||||
$error = null;
|
$error = null;
|
||||||
|
$responseBody = null;
|
||||||
|
$usage = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $request();
|
$response = $request();
|
||||||
$statusCode = $response->status();
|
$statusCode = $response->status();
|
||||||
|
$usage = $this->extractUsage($response);
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$body = $response->body();
|
||||||
|
$error = Str::limit($body, 1000);
|
||||||
|
$responseBody = $this->truncate($body);
|
||||||
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
|
|
||||||
|
// RequestException carries the response, ConnectionException
|
||||||
|
// doesn't. Pull the body when it's available.
|
||||||
|
if ($e instanceof RequestException) {
|
||||||
|
$responseBody = $this->truncate($e->response->body());
|
||||||
|
$usage = $this->extractUsage($e->response);
|
||||||
|
}
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
} finally {
|
} finally {
|
||||||
ApiLog::create([
|
ApiLog::create([
|
||||||
@@ -39,7 +67,51 @@ class ApiLogger
|
|||||||
'status_code' => $statusCode,
|
'status_code' => $statusCode,
|
||||||
'duration_ms' => (int) round((microtime(true) - $start) * 1000),
|
'duration_ms' => (int) round((microtime(true) - $start) * 1000),
|
||||||
'error' => $error,
|
'error' => $error,
|
||||||
|
'response_body' => $responseBody,
|
||||||
|
...$usage,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function truncate(string $body): string
|
||||||
|
{
|
||||||
|
return strlen($body) > self::RESPONSE_BODY_CAP
|
||||||
|
? substr($body, 0, self::RESPONSE_BODY_CAP)
|
||||||
|
: $body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull token-usage and rate-limit telemetry from a provider response.
|
||||||
|
*
|
||||||
|
* Today only Anthropic exposes both. Other providers return mostly
|
||||||
|
* NULLs — callers don't need to know which is which.
|
||||||
|
*
|
||||||
|
* @return array<string, int|string|null>
|
||||||
|
*/
|
||||||
|
private function extractUsage(?Response $response): array
|
||||||
|
{
|
||||||
|
if ($response === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = $response->json('usage');
|
||||||
|
$tokens = is_array($usage) ? $usage : [];
|
||||||
|
|
||||||
|
$reset = $response->header('anthropic-ratelimit-input-tokens-reset');
|
||||||
|
$remaining = $response->header('anthropic-ratelimit-input-tokens-remaining');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null),
|
||||||
|
'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null),
|
||||||
|
'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null),
|
||||||
|
'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null),
|
||||||
|
'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null),
|
||||||
|
'ratelimit_reset_at' => $reset !== '' ? $reset : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intOrNull(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,4 +41,24 @@ final readonly class BrentPriceFetcher
|
|||||||
|
|
||||||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-shot Brent backfill via FRED's observation_start/end. Used to
|
||||||
|
* seed `brent_prices` going back to 2018 so Phase 9's volatility
|
||||||
|
* detector and Phase 8's LLM overlay have proper context.
|
||||||
|
*
|
||||||
|
* @return int rows inserted/updated
|
||||||
|
*/
|
||||||
|
public function backfillFromFred(string $from, string $to): int
|
||||||
|
{
|
||||||
|
$rows = $this->fred->fetchRange($from, $to);
|
||||||
|
|
||||||
|
if ($rows === null) {
|
||||||
|
throw new BrentPriceFetchException("FRED backfill ({$from} → {$to}) returned no data");
|
||||||
|
}
|
||||||
|
|
||||||
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
|
|
||||||
|
return count($rows);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Services;
|
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Services\LlmPrediction\OilPredictionProvider;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
final class BrentPricePredictor
|
|
||||||
{
|
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
|
||||||
|
|
||||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
|
||||||
|
|
||||||
private const int EWMA_MIN_ROWS = 14;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly OilPredictionProvider $provider,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the latest BrentPrice row, or null if none exists.
|
|
||||||
*/
|
|
||||||
public function latestPrice(): ?BrentPrice
|
|
||||||
{
|
|
||||||
return BrentPrice::orderBy('date', 'desc')->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate EWMA + LLM predictions, store them, and flag the latest
|
|
||||||
* brent_prices row as having a prediction generated.
|
|
||||||
*/
|
|
||||||
public function generatePrediction(): ?PricePrediction
|
|
||||||
{
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
|
|
||||||
if ($prices->count() < self::EWMA_MIN_ROWS) {
|
|
||||||
Log::warning('BrentPricePredictor: not enough price data', [
|
|
||||||
'rows' => $prices->count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ewma = $this->generateEwmaPrediction($prices);
|
|
||||||
|
|
||||||
if ($ewma !== null) {
|
|
||||||
PricePrediction::create($ewma->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
$llm = $this->provider->predict($prices);
|
|
||||||
|
|
||||||
if ($llm !== null) {
|
|
||||||
PricePrediction::create($llm->toArray());
|
|
||||||
}
|
|
||||||
|
|
||||||
$result = $llm ?? $ewma;
|
|
||||||
|
|
||||||
if ($result !== null) {
|
|
||||||
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
|
|
||||||
{
|
|
||||||
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
|
|
||||||
|
|
||||||
if (count($chronological) < self::EWMA_MIN_ROWS) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
|
|
||||||
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
|
|
||||||
|
|
||||||
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
|
|
||||||
|
|
||||||
[$direction, $confidence] = match (true) {
|
|
||||||
$changePct >= self::EWMA_THRESHOLD_PCT => [
|
|
||||||
TrendDirection::Rising,
|
|
||||||
$this->ewmaConfidence($changePct),
|
|
||||||
],
|
|
||||||
$changePct <= -self::EWMA_THRESHOLD_PCT => [
|
|
||||||
TrendDirection::Falling,
|
|
||||||
$this->ewmaConfidence(abs($changePct)),
|
|
||||||
],
|
|
||||||
default => [TrendDirection::Flat, 50],
|
|
||||||
};
|
|
||||||
|
|
||||||
$reasoning = sprintf(
|
|
||||||
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
|
|
||||||
$ewma3,
|
|
||||||
$ewma7,
|
|
||||||
abs($changePct),
|
|
||||||
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
|
|
||||||
);
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => PredictionSource::Ewma,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => $confidence,
|
|
||||||
'reasoning' => $reasoning,
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param float[] $prices Chronological (oldest first).
|
|
||||||
*/
|
|
||||||
private function computeEwma(array $prices): float
|
|
||||||
{
|
|
||||||
$ema = $prices[0];
|
|
||||||
|
|
||||||
foreach (array_slice($prices, 1) as $price) {
|
|
||||||
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
|
|
||||||
}
|
|
||||||
|
|
||||||
return round($ema, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function ewmaConfidence(float $changePct): int
|
|
||||||
{
|
|
||||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
|
||||||
|
|
||||||
return (int) round(max(30, $scaled));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
namespace App\Services\BrentPriceSources;
|
namespace App\Services\BrentPriceSources;
|
||||||
|
|
||||||
use App\Services\ApiLogger;
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class EiaBrentPriceSource
|
final class EiaBrentPriceSource
|
||||||
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
|
|||||||
public function __construct(private readonly ApiLogger $apiLogger) {}
|
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{date: string, price_usd: float}[]|null
|
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
|
||||||
*/
|
*/
|
||||||
public function fetch(): ?array
|
public function fetch(): ?array
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
|
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
|
||||||
|
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||||
|
->throw()
|
||||||
->get(self::URL, [
|
->get(self::URL, [
|
||||||
'api_key' => config('services.eia.api_key'),
|
'api_key' => config('services.eia.api_key'),
|
||||||
'frequency' => 'daily',
|
'frequency' => 'daily',
|
||||||
@@ -29,11 +34,10 @@ final class EiaBrentPriceSource
|
|||||||
'sort[0][direction]' => 'desc',
|
'sort[0][direction]' => 'desc',
|
||||||
'length' => 30,
|
'length' => 30,
|
||||||
]));
|
]));
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
if (! $response->successful()) {
|
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
|
||||||
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
|
} catch (RequestException $e) {
|
||||||
|
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = collect($response->json('response.data') ?? [])
|
$rows = collect($response->json('response.data') ?? [])
|
||||||
@@ -44,17 +48,12 @@ final class EiaBrentPriceSource
|
|||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($rows === []) {
|
return $rows === [] ? null : $rows;
|
||||||
Log::warning('EiaBrentPriceSource: no valid observations returned');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rows;
|
private function shouldRetry(Throwable $e): bool
|
||||||
} catch (Throwable $e) {
|
{
|
||||||
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
|
return $e instanceof ConnectionException
|
||||||
|
|| ($e instanceof RequestException && $e->response->serverError());
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
namespace App\Services\BrentPriceSources;
|
namespace App\Services\BrentPriceSources;
|
||||||
|
|
||||||
use App\Services\ApiLogger;
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
final class FredBrentPriceSource
|
final class FredBrentPriceSource
|
||||||
@@ -14,24 +15,60 @@ final class FredBrentPriceSource
|
|||||||
public function __construct(private readonly ApiLogger $apiLogger) {}
|
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{date: string, price_usd: float}[]|null
|
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
|
||||||
*/
|
*/
|
||||||
public function fetch(): ?array
|
public function fetch(): ?array
|
||||||
{
|
{
|
||||||
try {
|
return $this->call([
|
||||||
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
|
|
||||||
->get(self::URL, [
|
|
||||||
'series_id' => 'DCOILBRENTEU',
|
|
||||||
'api_key' => config('services.fred.api_key'),
|
|
||||||
'sort_order' => 'desc',
|
'sort_order' => 'desc',
|
||||||
'limit' => 30,
|
'limit' => 30,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backfill range (inclusive). FRED's `observation_start` /
|
||||||
|
* `observation_end` parameters expect ISO dates (YYYY-MM-DD).
|
||||||
|
* Returns null when the range is empty (e.g. all weekends/holidays).
|
||||||
|
*
|
||||||
|
* @return array{date: string, price_usd: float}[]|null
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException
|
||||||
|
*/
|
||||||
|
public function fetchRange(string $from, string $to): ?array
|
||||||
|
{
|
||||||
|
return $this->call([
|
||||||
|
'observation_start' => $from,
|
||||||
|
'observation_end' => $to,
|
||||||
|
'sort_order' => 'asc',
|
||||||
|
'limit' => 100000,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, scalar> $extraParams
|
||||||
|
* @return array{date: string, price_usd: float}[]|null
|
||||||
|
*
|
||||||
|
* @throws BrentPriceFetchException
|
||||||
|
*/
|
||||||
|
private function call(array $extraParams): ?array
|
||||||
|
{
|
||||||
|
$params = array_merge([
|
||||||
|
'series_id' => 'DCOILBRENTEU',
|
||||||
|
'api_key' => config('services.fred.api_key'),
|
||||||
'file_type' => 'json',
|
'file_type' => 'json',
|
||||||
]));
|
], $extraParams);
|
||||||
|
|
||||||
if (! $response->successful()) {
|
try {
|
||||||
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
|
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(60)
|
||||||
|
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||||
return null;
|
->throw()
|
||||||
|
->get(self::URL, $params));
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
|
||||||
|
} catch (RequestException $e) {
|
||||||
|
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = collect($response->json('observations') ?? [])
|
$rows = collect($response->json('observations') ?? [])
|
||||||
@@ -42,17 +79,12 @@ final class FredBrentPriceSource
|
|||||||
])
|
])
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
if ($rows === []) {
|
return $rows === [] ? null : $rows;
|
||||||
Log::warning('FredBrentPriceSource: no valid observations returned');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $rows;
|
private function shouldRetry(Throwable $e): bool
|
||||||
} catch (Throwable $e) {
|
{
|
||||||
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
|
return $e instanceof ConnectionException
|
||||||
|
|| ($e instanceof RequestException && $e->response->serverError());
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Services/Forecasting/AccuracyHistory.php
Normal file
36
app/Services/Forecasting/AccuracyHistory.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trailing-13-week hit rate for a model_version. Read from
|
||||||
|
* `forecast_outcomes`. Returns null when fewer than 4 outcomes are
|
||||||
|
* available (a single bad week would otherwise dominate the ratio).
|
||||||
|
*/
|
||||||
|
final class AccuracyHistory
|
||||||
|
{
|
||||||
|
private const int WEEKS = 13;
|
||||||
|
|
||||||
|
private const int MIN_OUTCOMES = 4;
|
||||||
|
|
||||||
|
public function trailingHitRate(string $modelVersion): ?float
|
||||||
|
{
|
||||||
|
$cutoff = Carbon::now()->subWeeks(self::WEEKS)->toDateString();
|
||||||
|
|
||||||
|
$row = DB::table('forecast_outcomes')
|
||||||
|
->where('model_version', $modelVersion)
|
||||||
|
->where('forecast_for', '>=', $cutoff)
|
||||||
|
->selectRaw('COUNT(*) as total, SUM(CASE WHEN correct THEN 1 ELSE 0 END) as correct')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$total = (int) ($row->total ?? 0);
|
||||||
|
if ($total < self::MIN_OUTCOMES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) ($row->correct ?? 0) / $total;
|
||||||
|
}
|
||||||
|
}
|
||||||
162
app/Services/Forecasting/BacktestRunner.php
Normal file
162
app/Services/Forecasting/BacktestRunner.php
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use App\Models\Backtest;
|
||||||
|
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs a WeeklyForecastModel through a train/eval split and persists
|
||||||
|
* the result to the `backtests` table.
|
||||||
|
*
|
||||||
|
* Pipeline:
|
||||||
|
* 1. Generate the training and eval Monday lists from the date ranges.
|
||||||
|
* 2. Run LeakDetector against every Monday × every feature. Refuse to
|
||||||
|
* train if any source date is on or after a target Monday.
|
||||||
|
* 3. Train the model.
|
||||||
|
* 4. For each eval Monday: predict, look up actual ΔULSP from
|
||||||
|
* `weekly_pump_prices`, score directional accuracy + abs error.
|
||||||
|
* 5. Persist a Backtest row, return it.
|
||||||
|
*
|
||||||
|
* The `leak_suspected` flag is a *secondary* smell test (true when
|
||||||
|
* directional_accuracy > 75). Primary leak defence is step 2.
|
||||||
|
*/
|
||||||
|
final class BacktestRunner
|
||||||
|
{
|
||||||
|
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly LeakDetector $leakDetector = new LeakDetector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(
|
||||||
|
WeeklyForecastModel $model,
|
||||||
|
CarbonInterface $trainStart,
|
||||||
|
CarbonInterface $trainEnd,
|
||||||
|
CarbonInterface $evalStart,
|
||||||
|
CarbonInterface $evalEnd,
|
||||||
|
): Backtest {
|
||||||
|
$trainingMondays = $this->mondaysBetween($trainStart, $trainEnd);
|
||||||
|
$evalMondays = $this->mondaysBetween($evalStart, $evalEnd);
|
||||||
|
|
||||||
|
$spec = $model->featureSpec();
|
||||||
|
$report = $this->leakDetector->validate($spec, [...$trainingMondays, ...$evalMondays]);
|
||||||
|
if ($report->hasLeaks()) {
|
||||||
|
throw new LeakDetectorException($report);
|
||||||
|
}
|
||||||
|
|
||||||
|
$model->train($trainingMondays);
|
||||||
|
|
||||||
|
$correct = 0;
|
||||||
|
$totalScored = 0;
|
||||||
|
$absErrors = [];
|
||||||
|
$bins = [];
|
||||||
|
|
||||||
|
foreach ($evalMondays as $monday) {
|
||||||
|
$actualDelta = $this->actualDeltaPence($monday);
|
||||||
|
if ($actualDelta === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prediction = $model->predict($monday);
|
||||||
|
$actualDirection = $this->classifyDirection($actualDelta);
|
||||||
|
$hit = $prediction->direction === $actualDirection;
|
||||||
|
|
||||||
|
$totalScored++;
|
||||||
|
$absErrors[] = abs($prediction->magnitudePence - $actualDelta);
|
||||||
|
if ($hit) {
|
||||||
|
$correct++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
|
||||||
|
$bins[$bin] ??= ['correct' => 0, 'total' => 0];
|
||||||
|
$bins[$bin]['total']++;
|
||||||
|
if ($hit) {
|
||||||
|
$bins[$bin]['correct']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$directionalAccuracy = $totalScored === 0
|
||||||
|
? null
|
||||||
|
: round(($correct / $totalScored) * 100, 2);
|
||||||
|
|
||||||
|
$maePence = $absErrors === []
|
||||||
|
? null
|
||||||
|
: round((array_sum($absErrors) / count($absErrors)) / 100, 2);
|
||||||
|
|
||||||
|
$calibrationTable = [];
|
||||||
|
foreach ($bins as $key => $b) {
|
||||||
|
$calibrationTable[$key] = round($b['correct'] / $b['total'], 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Backtest::create([
|
||||||
|
'model_version' => $spec->modelVersion(),
|
||||||
|
'features_json' => $spec->toArray(),
|
||||||
|
'coefficients_json' => $model->coefficients(),
|
||||||
|
'train_start' => $trainStart->toDateString(),
|
||||||
|
'train_end' => $trainEnd->toDateString(),
|
||||||
|
'eval_start' => $evalStart->toDateString(),
|
||||||
|
'eval_end' => $evalEnd->toDateString(),
|
||||||
|
'directional_accuracy' => $directionalAccuracy,
|
||||||
|
'mae_pence' => $maePence,
|
||||||
|
'calibration_table' => $calibrationTable,
|
||||||
|
'leak_suspected' => $directionalAccuracy !== null && $directionalAccuracy > 75.0,
|
||||||
|
'ran_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, CarbonInterface> */
|
||||||
|
private function mondaysBetween(CarbonInterface $start, CarbonInterface $end): array
|
||||||
|
{
|
||||||
|
$mondays = [];
|
||||||
|
$cursor = $start->copy()->startOfDay();
|
||||||
|
$boundary = $end->copy()->startOfDay();
|
||||||
|
|
||||||
|
while ($cursor->lessThanOrEqualTo($boundary)) {
|
||||||
|
if ($cursor->dayOfWeek === CarbonInterface::MONDAY) {
|
||||||
|
$mondays[] = $cursor->copy();
|
||||||
|
}
|
||||||
|
$cursor = $cursor->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mondays;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
|
||||||
|
{
|
||||||
|
$current = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', $targetMonday->toDateString())
|
||||||
|
->value('ulsp_pence');
|
||||||
|
$previous = DB::table('weekly_pump_prices')
|
||||||
|
->where('date', $targetMonday->copy()->subDays(7)->toDateString())
|
||||||
|
->value('ulsp_pence');
|
||||||
|
|
||||||
|
if ($current === null || $previous === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (float) ($current - $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function classifyDirection(float $deltaPence): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
|
||||||
|
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
|
||||||
|
default => 'flat',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bucketForMagnitude(float $magnitudePence): string
|
||||||
|
{
|
||||||
|
$abs = abs($magnitudePence);
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$abs < 50.0 => '0.0-0.5p',
|
||||||
|
$abs < 100.0 => '0.5-1.0p',
|
||||||
|
default => '1.0p+',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
138
app/Services/Forecasting/BeisImporter.php
Normal file
138
app/Services/Forecasting/BeisImporter.php
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pulls the latest "Weekly road fuel prices (CSV) 2018 to 2026"
|
||||||
|
* attachment from gov.uk's content API and upserts into
|
||||||
|
* `weekly_pump_prices`.
|
||||||
|
*
|
||||||
|
* Idempotent: re-running on a day with no new publication is a no-op
|
||||||
|
* (rows match by primary key `date`, content is unchanged).
|
||||||
|
*
|
||||||
|
* The forecast cache is busted at the end so the next API hit retrains
|
||||||
|
* the ridge model on the fresh row.
|
||||||
|
*/
|
||||||
|
final class BeisImporter
|
||||||
|
{
|
||||||
|
private const string API_URL = 'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices';
|
||||||
|
|
||||||
|
private const string ATTACHMENT_TITLE = 'Weekly road fuel prices (CSV) 2018 to 2026';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* csv_url: string,
|
||||||
|
* parsed: int,
|
||||||
|
* upserted: int,
|
||||||
|
* latest_date: string,
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function import(): array
|
||||||
|
{
|
||||||
|
$url = $this->resolveCsvUrl();
|
||||||
|
$csv = $this->downloadCsv($url);
|
||||||
|
$rows = $this->parse($csv);
|
||||||
|
|
||||||
|
if ($rows === []) {
|
||||||
|
throw new RuntimeException('BEIS CSV parsed empty — check delimiter / encoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('weekly_pump_prices')->upsert(
|
||||||
|
$rows,
|
||||||
|
['date'],
|
||||||
|
['ulsp_pence', 'ulsd_pence', 'ulsp_duty_pence', 'ulsd_duty_pence', 'ulsp_vat_pct', 'ulsd_vat_pct'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$latest = (string) collect($rows)->pluck('date')->sortDesc()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'csv_url' => $url,
|
||||||
|
'parsed' => count($rows),
|
||||||
|
'upserted' => count($rows),
|
||||||
|
'latest_date' => $latest,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveCsvUrl(): string
|
||||||
|
{
|
||||||
|
$response = Http::timeout(15)->acceptJson()->get(self::API_URL);
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
$attachments = $response->json('details.attachments', []);
|
||||||
|
foreach ($attachments as $a) {
|
||||||
|
if (($a['title'] ?? null) === self::ATTACHMENT_TITLE) {
|
||||||
|
$url = $a['url'] ?? null;
|
||||||
|
if (! is_string($url) || $url === '') {
|
||||||
|
throw new RuntimeException('BEIS attachment had empty URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'gov.uk content API did not return an attachment titled %s',
|
||||||
|
self::ATTACHMENT_TITLE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function downloadCsv(string $url): string
|
||||||
|
{
|
||||||
|
$response = Http::timeout(60)->get($url);
|
||||||
|
$response->throw();
|
||||||
|
|
||||||
|
return $response->body();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, int|string>>
|
||||||
|
*/
|
||||||
|
private function parse(string $csv): array
|
||||||
|
{
|
||||||
|
$rows = [];
|
||||||
|
$lines = preg_split('/\r\n|\r|\n/', $csv);
|
||||||
|
if ($lines === false || count($lines) < 2) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip header.
|
||||||
|
array_shift($lines);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$line = trim($line);
|
||||||
|
if ($line === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cols = str_getcsv($line, escape: '\\');
|
||||||
|
if (count($cols) < 7) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$date = DateTime::createFromFormat('d/m/Y', trim($cols[0]));
|
||||||
|
if ($date === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows[] = [
|
||||||
|
'date' => $date->format('Y-m-d'),
|
||||||
|
'ulsp_pence' => (int) round(((float) $cols[1]) * 100),
|
||||||
|
'ulsd_pence' => (int) round(((float) $cols[2]) * 100),
|
||||||
|
'ulsp_duty_pence' => (int) round(((float) $cols[3]) * 100),
|
||||||
|
'ulsd_duty_pence' => (int) round(((float) $cols[4]) * 100),
|
||||||
|
'ulsp_vat_pct' => (int) $cols[5],
|
||||||
|
'ulsd_vat_pct' => (int) $cols[6],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal file
33
app/Services/Forecasting/Contracts/ForecastFeature.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Contracts;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single feature in a weekly forecast model.
|
||||||
|
*
|
||||||
|
* Implementations must be deterministic for a given target Monday and
|
||||||
|
* must declare every source date they read so the LeakDetector can
|
||||||
|
* verify no source date is on or after the target Monday.
|
||||||
|
*/
|
||||||
|
interface ForecastFeature
|
||||||
|
{
|
||||||
|
public function name(): string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature value at $targetMonday, or null when an upstream data
|
||||||
|
* row is missing. Caller is expected to drop the entire feature
|
||||||
|
* vector when any single feature is null.
|
||||||
|
*/
|
||||||
|
public function valueFor(CarbonInterface $targetMonday): ?float;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every date this feature reads from any data source for a given
|
||||||
|
* target Monday. The LeakDetector requires every returned date to
|
||||||
|
* be strictly before $targetMonday.
|
||||||
|
*
|
||||||
|
* @return array<int, CarbonInterface>
|
||||||
|
*/
|
||||||
|
public function sourceDates(CarbonInterface $targetMonday): array;
|
||||||
|
}
|
||||||
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal file
40
app/Services/Forecasting/Contracts/WeeklyForecastModel.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting\Contracts;
|
||||||
|
|
||||||
|
use App\Services\Forecasting\FeatureSpec;
|
||||||
|
use App\Services\Forecasting\WeeklyPrediction;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contract every weekly forecaster must satisfy. The harness consumes
|
||||||
|
* this interface — naive baselines, ridge regression, and any future
|
||||||
|
* model all implement it.
|
||||||
|
*/
|
||||||
|
interface WeeklyForecastModel
|
||||||
|
{
|
||||||
|
public function featureSpec(): FeatureSpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Train on the supplied weeks. Implementations may store coefficients
|
||||||
|
* internally for the subsequent predict() calls.
|
||||||
|
*
|
||||||
|
* @param array<int, CarbonInterface> $trainingMondays
|
||||||
|
*/
|
||||||
|
public function train(array $trainingMondays): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Predict ΔULSP for the week starting $targetMonday. Returned value
|
||||||
|
* is in pence × 100 (integer-ish, but typed float for fractional
|
||||||
|
* predictions).
|
||||||
|
*/
|
||||||
|
public function predict(CarbonInterface $targetMonday): WeeklyPrediction;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coefficients in a JSON-serialisable form, or null for non-parametric
|
||||||
|
* models like the naive baseline.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public function coefficients(): ?array;
|
||||||
|
}
|
||||||
45
app/Services/Forecasting/DutyChangeDetector.php
Normal file
45
app/Services/Forecasting/DutyChangeDetector.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Forecasting;
|
||||||
|
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flags forecast weeks that fall within ±4 weeks of a known UK fuel
|
||||||
|
* duty change. Per the spec calibration override (n=1), the displayed
|
||||||
|
* confidence on flagged weeks is halved and the reasoning text says so.
|
||||||
|
*/
|
||||||
|
final class DutyChangeDetector
|
||||||
|
{
|
||||||
|
public const int FLAG_RADIUS_WEEKS = 4;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the target Monday is within ±4 weeks of any
|
||||||
|
* change in `weekly_pump_prices.ulsp_duty_pence`.
|
||||||
|
*/
|
||||||
|
public function isAdjacent(CarbonInterface $targetMonday): bool
|
||||||
|
{
|
||||||
|
$start = $targetMonday->copy()->subWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
|
||||||
|
$end = $targetMonday->copy()->addWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
|
||||||
|
|
||||||
|
$rows = DB::table('weekly_pump_prices')
|
||||||
|
->whereBetween('date', [$start, $end])
|
||||||
|
->orderBy('date')
|
||||||
|
->get(['date', 'ulsp_duty_pence']);
|
||||||
|
|
||||||
|
if ($rows->count() < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$previous = null;
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
if ($previous !== null && (int) $r->ulsp_duty_pence !== $previous) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$previous = (int) $r->ulsp_duty_pence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user