Compare commits
74 Commits
a969c1b347
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
aec547cd86 | ||
|
|
486f0e689c | ||
|
|
1a0381265e | ||
|
|
a7ee9f4557 | ||
|
|
4220b1b86a | ||
|
|
3cd3467178 | ||
|
|
d25883ead4 |
@@ -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"
|
||||||
|
---
|
||||||
409
.claude/rules/tiers.md
Normal file
409
.claude/rules/tiers.md
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
# Tier & Entitlement System — Claude Code Rules
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
FuelAlert uses a plan-based entitlement system. Every decision about what a user
|
||||||
|
can receive, on which channel, at what frequency, is resolved through a single
|
||||||
|
`PlanFeatures` service. Nothing else makes entitlement decisions.
|
||||||
|
|
||||||
|
Users subscribe via Stripe (Laravel Cashier). The active plan is resolved from
|
||||||
|
the Stripe subscription's price ID, mapped to a `Plan` model row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tiers
|
||||||
|
|
||||||
|
| Tier | Price | Stripe Price Env Key |
|
||||||
|
|-------|--------|-------------------------------|
|
||||||
|
| free | £0 | — |
|
||||||
|
| basic | £0.99 | `STRIPE_PRICE_BASIC` |
|
||||||
|
| plus | £2.49 | `STRIPE_PRICE_PLUS` |
|
||||||
|
| pro | £3.99 | `STRIPE_PRICE_PRO` |
|
||||||
|
|
||||||
|
A user with no active Cashier subscription is always resolved as `free`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fuel Types
|
||||||
|
|
||||||
|
Six fuel types exist across the app:
|
||||||
|
|
||||||
|
```
|
||||||
|
E10, E5, B7_STANDARD, B7_PREMIUM, B10, HVO
|
||||||
|
```
|
||||||
|
|
||||||
|
All six are available to all tiers. The restriction is quantity only:
|
||||||
|
|
||||||
|
| Tier | Max tracked fuel types |
|
||||||
|
|---------------|------------------------|
|
||||||
|
| free | 1 |
|
||||||
|
| basic | 1 |
|
||||||
|
| plus | 1 |
|
||||||
|
| pro | unlimited (null) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Channels
|
||||||
|
|
||||||
|
| Channel | free | basic | plus | pro |
|
||||||
|
|-----------|---------------|--------------|--------------|--------------|
|
||||||
|
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
|
||||||
|
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||||
|
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
|
||||||
|
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day |
|
||||||
|
|
||||||
|
WhatsApp also supports scheduled updates (morning + evening) independent of
|
||||||
|
price triggers — available to any tier that has WhatsApp enabled.
|
||||||
|
|
||||||
|
Channel daily limits (`sms_daily_limit`, `whatsapp_daily_limit`) are enforced
|
||||||
|
by counting rows in `notification_log` for `(user_id, channel, DATE(created_at))`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Triggers
|
||||||
|
|
||||||
|
| Trigger | Description |
|
||||||
|
|----------------------|----------------------------------------------------------|
|
||||||
|
| `price_threshold` | Price drops at or below the user's saved threshold |
|
||||||
|
| `score_change` | Fill-up score flips good↔bad for a fuel type |
|
||||||
|
| `scheduled_morning` | WhatsApp scheduled update — fired by scheduler |
|
||||||
|
| `scheduled_evening` | WhatsApp scheduled update — fired by scheduler |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enum
|
||||||
|
|
||||||
|
Use a `PlanTier` backed enum at `app/Enums/PlanTier.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
enum PlanTier: string
|
||||||
|
{
|
||||||
|
case Free = 'free';
|
||||||
|
case Basic = 'basic';
|
||||||
|
case Plus = 'plus';
|
||||||
|
case Pro = 'pro';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference `PlanTier::Free->value` everywhere, never raw strings.
|
||||||
|
|
||||||
|
|
||||||
|
## Stripe Price IDs
|
||||||
|
|
||||||
|
Each tier has two prices:
|
||||||
|
|
||||||
|
| Tier | Monthly Env Key | Annual Env Key |
|
||||||
|
|-------|--------------------------------|-------------------------------|
|
||||||
|
| basic | `STRIPE_PRICE_BASIC_MONTHLY` | `STRIPE_PRICE_BASIC_ANNUAL` |
|
||||||
|
| plus | `STRIPE_PRICE_PLUS_MONTHLY` | `STRIPE_PRICE_PLUS_ANNUAL` |
|
||||||
|
| pro | `STRIPE_PRICE_PRO_MONTHLY` | `STRIPE_PRICE_PRO_ANNUAL` |
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### `plans` table
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
name string — free | basic | plus | pro
|
||||||
|
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
|
||||||
|
active boolean default true
|
||||||
|
timestamps
|
||||||
|
```
|
||||||
|
|
||||||
|
**`features` JSON shape:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"fuel_types": {
|
||||||
|
"max": 1
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"enabled": true,
|
||||||
|
"frequency": "triggered"
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"enabled": true,
|
||||||
|
"frequency": "triggered"
|
||||||
|
},
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"daily_limit": 5,
|
||||||
|
"scheduled_updates": 2
|
||||||
|
},
|
||||||
|
"sms": {
|
||||||
|
"enabled": true,
|
||||||
|
"daily_limit": 3
|
||||||
|
},
|
||||||
|
"ai_predictions": true,
|
||||||
|
"price_threshold": true,
|
||||||
|
"score_alerts": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
|
||||||
|
`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
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
user_id unsignedBigInteger FK users.id cascadeDelete
|
||||||
|
channel string — email | push | whatsapp | sms
|
||||||
|
fuel_type string — E10 | E5 | B7_STANDARD | B7_PREMIUM | B10 | HVO
|
||||||
|
enabled boolean default true
|
||||||
|
timestamps
|
||||||
|
unique([user_id, channel, fuel_type])
|
||||||
|
```
|
||||||
|
|
||||||
|
The user opts channels in/out here. The tier is the ceiling — if the plan does
|
||||||
|
not allow a channel, this preference is ignored regardless of its value.
|
||||||
|
|
||||||
|
### `notification_log` table
|
||||||
|
|
||||||
|
```
|
||||||
|
id unsignedBigInteger PK
|
||||||
|
user_id unsignedBigInteger FK users.id cascadeDelete
|
||||||
|
channel string
|
||||||
|
trigger_type string — price_threshold | score_change | scheduled_morning | scheduled_evening
|
||||||
|
fuel_type string
|
||||||
|
price decimal(8,3) nullable
|
||||||
|
sent boolean
|
||||||
|
missed_reason string nullable — daily_limit | tier_restricted | user_disabled
|
||||||
|
created_at timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
No `updated_at` — this is append-only. Index on `(user_id, channel, created_at)`
|
||||||
|
for daily limit queries. Index on `(user_id, sent, created_at)` for dashboard
|
||||||
|
missed-count queries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
### `Plan`
|
||||||
|
|
||||||
|
- Casts `features` to `array`.
|
||||||
|
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
|
||||||
|
active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
|
||||||
|
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, ...)`.
|
||||||
|
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
|
||||||
|
|
||||||
|
### `UserNotificationPreference`
|
||||||
|
|
||||||
|
- `belongsTo(User::class)`
|
||||||
|
- Scope: `scopeEnabled($query)` — where enabled = true
|
||||||
|
- Scope: `scopeForChannel($query, string $channel)`
|
||||||
|
- Scope: `scopeForFuelType($query, string $fuelType)`
|
||||||
|
|
||||||
|
### `NotificationLog`
|
||||||
|
|
||||||
|
- `belongsTo(User::class)`
|
||||||
|
- Scope: `scopeSentToday($query, string $channel)` — counts today's sent rows
|
||||||
|
- Scope: `scopeMissed($query)` — where sent = false
|
||||||
|
- Never update rows — only insert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `PlanFeatures` Service
|
||||||
|
|
||||||
|
Located at `app/Services/PlanFeatures.php`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
PlanFeatures::for($user)->canUseChannel('sms') // bool — tier allows it
|
||||||
|
PlanFeatures::for($user)->canSendNow('sms') // bool — tier allows + under daily limit
|
||||||
|
PlanFeatures::for($user)->channelsFor('price_threshold') // string[] — allowed + user-enabled + under limit
|
||||||
|
PlanFeatures::for($user)->canTrackFuelType(string $type) // bool — under max
|
||||||
|
PlanFeatures::for($user)->trackedFuelTypeCount() // int
|
||||||
|
PlanFeatures::for($user)->fuelTypeLimit() // int|null
|
||||||
|
PlanFeatures::for($user)->can('ai_predictions') // bool — generic feature flag
|
||||||
|
PlanFeatures::for($user)->missedToday(string $channel) // int — for dashboard
|
||||||
|
PlanFeatures::for($user)->missedThisMonth(string $channel)// int — for digest email
|
||||||
|
PlanFeatures::for($user)->tier() // string — free|basic|plus|pro
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rules:**
|
||||||
|
- `canSendNow` always checks both the plan cap AND the live `notification_log`
|
||||||
|
count for today. Never skip either check.
|
||||||
|
- `channelsFor($triggerType)` is the method used by the dispatch job. It returns
|
||||||
|
only channels that pass: tier allows → user has enabled → daily limit not hit.
|
||||||
|
- The service must never throw. If the plan cannot be resolved, treat as `free`.
|
||||||
|
- The service is the **only** place daily limit logic lives. Jobs and controllers
|
||||||
|
call `PlanFeatures`, never query `notification_log` directly for limit checks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `RequiresFeature` Middleware
|
||||||
|
|
||||||
|
Registered as `feature` in `bootstrap/app.php`.
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Usage in routes
|
||||||
|
Route::get('/predictions', PredictionsController::class)
|
||||||
|
->middleware('feature:ai_predictions');
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns `403 { "error": "upgrade_required", "feature": "ai_predictions" }` if
|
||||||
|
`PlanFeatures::for($request->user())->can($feature)` is false.
|
||||||
|
|
||||||
|
Only use for route-level feature gates. Channel-level logic stays in the job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notification Dispatch Flow
|
||||||
|
|
||||||
|
### Price update (every 15 min)
|
||||||
|
|
||||||
|
```
|
||||||
|
PriceUpdated event (fired by polling job)
|
||||||
|
└── ProcessPriceAlerts job (queued, single instance via WithoutOverlapping)
|
||||||
|
├── Find users whose threshold >= new price for this fuel type
|
||||||
|
├── Find users subscribed to score_change if score flipped
|
||||||
|
├── Chunk users → dispatch DispatchUserNotification job per user
|
||||||
|
```
|
||||||
|
|
||||||
|
### `DispatchUserNotification` job
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Load plan via Plan::resolveForUser($user) — cached
|
||||||
|
2. Instantiate PlanFeatures::for($user)
|
||||||
|
3. $channels = $features->channelsFor($triggerType) — filtered list
|
||||||
|
4. foreach $channels as $channel:
|
||||||
|
a. Send via the appropriate Laravel Notification class
|
||||||
|
b. Log to notification_log (sent: true)
|
||||||
|
5. foreach skipped channels (tier allows but limit hit):
|
||||||
|
a. Log to notification_log (sent: false, missed_reason: daily_limit)
|
||||||
|
6. foreach tier-blocked channels the user had enabled in prefs:
|
||||||
|
a. Log to notification_log (sent: false, missed_reason: tier_restricted)
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not log channels the user has manually disabled (`user_disabled` would be
|
||||||
|
noise — those are intentional).
|
||||||
|
|
||||||
|
### Scheduled WhatsApp updates
|
||||||
|
|
||||||
|
Two scheduler entries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Schedule::job(SendScheduledWhatsApp::class, 'morning')->dailyAt('07:30');
|
||||||
|
Schedule::job(SendScheduledWhatsApp::class, 'evening')->dailyAt('18:00');
|
||||||
|
```
|
||||||
|
|
||||||
|
`SendScheduledWhatsApp` queries all users where:
|
||||||
|
- Plan has `whatsapp.scheduled_updates > 0`
|
||||||
|
- User has whatsapp preference enabled
|
||||||
|
- `canSendNow('whatsapp')` is true at dispatch time
|
||||||
|
|
||||||
|
Same logging rules apply.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filament `PlanResource`
|
||||||
|
|
||||||
|
Located in the admin panel. Edits the `features` JSON column using explicit form
|
||||||
|
fields — never a raw key-value editor.
|
||||||
|
|
||||||
|
**Form fields:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Section: Fuel Types
|
||||||
|
- NumberInput fuel_types.max (null = unlimited, label: "Max fuel types — leave blank for unlimited")
|
||||||
|
|
||||||
|
Section: Email
|
||||||
|
- Toggle email.enabled
|
||||||
|
- Select email.frequency options: weekly_digest | daily | triggered
|
||||||
|
|
||||||
|
Section: Push
|
||||||
|
- Toggle push.enabled
|
||||||
|
|
||||||
|
Section: WhatsApp
|
||||||
|
- Toggle whatsapp.enabled
|
||||||
|
- NumberInput whatsapp.daily_limit
|
||||||
|
- NumberInput whatsapp.scheduled_updates
|
||||||
|
|
||||||
|
Section: SMS
|
||||||
|
- Toggle sms.enabled
|
||||||
|
- NumberInput sms.daily_limit
|
||||||
|
|
||||||
|
Section: Features
|
||||||
|
- Toggle ai_predictions
|
||||||
|
- Toggle price_threshold
|
||||||
|
- Toggle score_alerts
|
||||||
|
```
|
||||||
|
|
||||||
|
On save, bust `Cache::tags(['plans'])`.
|
||||||
|
|
||||||
|
Do not allow deleting plan rows — disable the `DeleteAction` on the resource.
|
||||||
|
Do not allow creating new plan rows from the UI — the four tiers are seeded.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filament Dashboard Widget — Missed Notifications
|
||||||
|
|
||||||
|
A `StatsOverviewWidget` on the user detail page (or a standalone widget) showing:
|
||||||
|
|
||||||
|
```
|
||||||
|
SMS missed today: 3 [Upgrade to Pro]
|
||||||
|
WhatsApp missed today: 0
|
||||||
|
Total missed this month: 12
|
||||||
|
```
|
||||||
|
|
||||||
|
Data sourced from `NotificationLog::scopeMissed()` queries. This data also feeds
|
||||||
|
the weekly/monthly digest email — the mailable receives the counts and renders
|
||||||
|
a "you missed X alerts — upgrade" block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Seeder
|
||||||
|
|
||||||
|
A `PlanSeeder` must exist that creates or updates all four plan rows with correct
|
||||||
|
default feature values. It must be idempotent (`updateOrCreate` on `name`).
|
||||||
|
Run as part of `DatabaseSeeder` in production-safe seeders.
|
||||||
|
|
||||||
|
```php
|
||||||
|
php artisan db:seed --class=PlanSeeder
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Expectations
|
||||||
|
|
||||||
|
Every entitlement check must have a Pest feature test:
|
||||||
|
|
||||||
|
- `canUseChannel` returns false when tier doesn't allow it
|
||||||
|
- `canSendNow` returns false when daily limit is reached
|
||||||
|
- `channelsFor` returns correct filtered list for each tier
|
||||||
|
- `canTrackFuelType` enforces max correctly, null = unlimited
|
||||||
|
- Middleware returns 403 with correct JSON for missing feature
|
||||||
|
- `DispatchUserNotification` job logs missed_reason correctly
|
||||||
|
- `PlanSeeder` is idempotent
|
||||||
|
|
||||||
|
Use factories for `Plan`, `User`, `UserNotificationPreference`, `NotificationLog`.
|
||||||
|
The `Plan` factory should accept a `tier` state: `Plan::factory()->pro()->create()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Must Never Happen
|
||||||
|
|
||||||
|
- Never query `notification_log` for limit checks outside `PlanFeatures`
|
||||||
|
- Never hardcode tier names as strings outside `Plan::TIERS` constant or an Enum
|
||||||
|
- Never send a notification without logging it
|
||||||
|
- Never bypass `PlanFeatures` in a job or controller "just this once"
|
||||||
|
- Never allow the `features` JSON to be partially saved — always merge full shape
|
||||||
|
- Never add a new feature to the JSON without adding a corresponding method to
|
||||||
|
`PlanFeatures` and updating the `PlanSeeder`
|
||||||
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 Finder"
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://fuel-price.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-price.test
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=log
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
@@ -64,4 +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=
|
||||||
|
|
||||||
|
ONESIGNAL_APP_ID=
|
||||||
|
ONESIGNAL_API_KEY=
|
||||||
|
|
||||||
|
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_ANNUAL=
|
||||||
|
|
||||||
|
SANCTUM_STATEFUL_DOMAINS=fuel-price.test
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ yarn-error.log
|
|||||||
/.vscode
|
/.vscode
|
||||||
/.zed
|
/.zed
|
||||||
/.tmp/
|
/.tmp/
|
||||||
|
/.worktrees/
|
||||||
|
/ONSPD_Online_Latest_Centroids_*.csv
|
||||||
|
|||||||
18
CLAUDE.md
18
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,7 +45,9 @@ 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/livewire.md
|
@.claude/rules/livewire.md
|
||||||
@.claude/rules/api-data.md
|
@.claude/rules/api-data.md
|
||||||
@.claude/rules/testing.md
|
@.claude/rules/testing.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Console/Commands/FetchOilPrices.php
Normal file
40
app/Console/Commands/FetchOilPrices.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?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;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
#[Signature('oil:fetch')]
|
||||||
|
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
|
||||||
|
class FetchOilPrices extends Command
|
||||||
|
{
|
||||||
|
public function handle(BrentPriceFetcher $fetcher): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$fetcher->fetchFromEia();
|
||||||
|
$this->info('Fetched Brent prices from EIA.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} 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...');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$fetcher->fetchFromFred();
|
||||||
|
$this->info('Fetched Brent prices from FRED.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
} catch (BrentPriceFetchException $e) {
|
||||||
|
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
|
||||||
|
$this->error('Both EIA and FRED failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,37 @@
|
|||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use App\Services\OilPriceService;
|
use App\Services\BrentPricePredictor;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class PredictOilPrices extends Command
|
class PredictOilPrices extends Command
|
||||||
{
|
{
|
||||||
protected $signature = 'oil:predict {--fetch : Fetch latest FRED prices before predicting}';
|
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';
|
protected $description = 'Generate a Brent crude oil price direction prediction';
|
||||||
|
|
||||||
public function handle(OilPriceService $service): int
|
public function handle(BrentPricePredictor $predictor): int
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if ($this->option('fetch')) {
|
$latest = $predictor->latestPrice();
|
||||||
$this->info('Fetching latest Brent crude prices from FRED...');
|
|
||||||
$service->fetchBrentPrices();
|
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...');
|
$this->info('Generating prediction...');
|
||||||
$prediction = $service->generatePrediction();
|
$prediction = $predictor->generatePrediction();
|
||||||
|
|
||||||
if ($prediction === null) {
|
if ($prediction === null) {
|
||||||
$this->error('Could not generate a prediction — not enough price data.');
|
$this->error('Could not generate a prediction — not enough price data.');
|
||||||
|
|||||||
@@ -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}"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
21
app/Enums/PlanTier.php
Normal file
21
app/Enums/PlanTier.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum PlanTier: string
|
||||||
|
{
|
||||||
|
case Free = 'free';
|
||||||
|
case Basic = 'basic';
|
||||||
|
case Plus = 'plus';
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ namespace App\Filament\Resources\BrentPriceResource\Pages;
|
|||||||
|
|
||||||
use App\Filament\Resources\BrentPriceResource;
|
use App\Filament\Resources\BrentPriceResource;
|
||||||
use App\Filament\Widgets\BrentPriceChartWidget;
|
use App\Filament\Widgets\BrentPriceChartWidget;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Resources\Pages\ListRecords;
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
class ListBrentPrices extends ListRecords
|
class ListBrentPrices extends ListRecords
|
||||||
{
|
{
|
||||||
@@ -12,7 +15,30 @@ class ListBrentPrices extends ListRecords
|
|||||||
|
|
||||||
protected function getHeaderActions(): array
|
protected function getHeaderActions(): array
|
||||||
{
|
{
|
||||||
return [];
|
return [
|
||||||
|
Action::make('fetchPrices')
|
||||||
|
->label('Fetch Prices Now')
|
||||||
|
->icon('heroicon-o-arrow-down-tray')
|
||||||
|
->requiresConfirmation()
|
||||||
|
->modalHeading('Fetch latest Brent prices?')
|
||||||
|
->modalDescription('Pulls the latest Brent crude prices from EIA (falls back to FRED if EIA is unavailable).')
|
||||||
|
->action(function () {
|
||||||
|
$result = Artisan::call('oil:fetch');
|
||||||
|
|
||||||
|
if ($result === 0) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Brent prices fetched successfully')
|
||||||
|
->success()
|
||||||
|
->send();
|
||||||
|
} else {
|
||||||
|
Notification::make()
|
||||||
|
->title('Fetch failed')
|
||||||
|
->body('Both EIA and FRED failed. Check API Logs for details.')
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function getHeaderWidgets(): array
|
protected function getHeaderWidgets(): array
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ class ListOilPredictions extends ListRecords
|
|||||||
->icon('heroicon-o-cpu-chip')
|
->icon('heroicon-o-cpu-chip')
|
||||||
->requiresConfirmation()
|
->requiresConfirmation()
|
||||||
->modalHeading('Run oil price prediction?')
|
->modalHeading('Run oil price prediction?')
|
||||||
->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.')
|
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
|
||||||
->action(function () {
|
->action(function () {
|
||||||
$result = Artisan::call('oil:predict', ['--fetch' => true]);
|
$result = Artisan::call('oil:predict', ['--force' => true]);
|
||||||
|
|
||||||
if ($result === 0) {
|
if ($result === 0) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal file
24
app/Filament/Resources/Plans/Pages/EditPlan.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Plans\PlanResource;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class EditPlan extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
if (Cache::supportsTags()) {
|
||||||
|
Cache::tags(['plans'])->flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal file
16
app/Filament/Resources/Plans/Pages/ListPlans.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Plans\PlanResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPlans extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PlanResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/PlanResource.php
Normal file
40
app/Filament/Resources/Plans/PlanResource.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans;
|
||||||
|
|
||||||
|
use App\Filament\NavigationGroup;
|
||||||
|
use App\Filament\Resources\Plans\Pages\EditPlan;
|
||||||
|
use App\Filament\Resources\Plans\Pages\ListPlans;
|
||||||
|
use App\Filament\Resources\Plans\Schemas\PlanForm;
|
||||||
|
use App\Filament\Resources\Plans\Tables\PlansTable;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PlanResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Plan::class;
|
||||||
|
|
||||||
|
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
|
||||||
|
|
||||||
|
protected static ?int $navigationSort = 10;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PlanForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PlansTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPlans::route('/'),
|
||||||
|
'edit' => EditPlan::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal file
99
app/Filament/Resources/Plans/Schemas/PlanForm.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\Select;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PlanForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make('Fuel Types')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('max_fuel_types')
|
||||||
|
->label('Max fuel types')
|
||||||
|
->helperText('Leave blank for unlimited.')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(1)
|
||||||
|
->nullable(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Email')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('email_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
Select::make('email_frequency')
|
||||||
|
->label('Frequency')
|
||||||
|
->options([
|
||||||
|
'weekly_digest' => 'Weekly digest',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'triggered' => 'Triggered',
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Push')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('push_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
Select::make('push_frequency')
|
||||||
|
->label('Frequency')
|
||||||
|
->options([
|
||||||
|
'none' => 'None (disabled)',
|
||||||
|
'daily' => 'Daily',
|
||||||
|
'triggered' => 'Triggered',
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('WhatsApp')
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('whatsapp_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
TextInput::make('whatsapp_daily_limit')
|
||||||
|
->label('Daily limit')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
TextInput::make('whatsapp_scheduled_updates')
|
||||||
|
->label('Scheduled updates per day')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('SMS')
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
Toggle::make('sms_enabled')
|
||||||
|
->label('Enabled'),
|
||||||
|
TextInput::make('sms_daily_limit')
|
||||||
|
->label('Daily limit')
|
||||||
|
->numeric()
|
||||||
|
->integer()
|
||||||
|
->minValue(0)
|
||||||
|
->required(),
|
||||||
|
]),
|
||||||
|
|
||||||
|
Section::make('Features')
|
||||||
|
->schema([
|
||||||
|
Toggle::make('ai_predictions')
|
||||||
|
->label('AI predictions'),
|
||||||
|
Toggle::make('price_threshold')
|
||||||
|
->label('Price threshold alerts'),
|
||||||
|
Toggle::make('score_alerts')
|
||||||
|
->label('Score change alerts'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal file
40
app/Filament/Resources/Plans/Tables/PlansTable.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Plans\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PlansTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->label('Tier')
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('email_frequency')
|
||||||
|
->label('Email')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('sms_daily_limit')
|
||||||
|
->label('SMS/day')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('whatsapp_daily_limit')
|
||||||
|
->label('WhatsApp/day')
|
||||||
|
->placeholder('—'),
|
||||||
|
TextColumn::make('max_fuel_types')
|
||||||
|
->label('Fuel types')
|
||||||
|
->placeholder('Unlimited'),
|
||||||
|
IconColumn::make('active')
|
||||||
|
->boolean(),
|
||||||
|
])
|
||||||
|
->defaultSort('id', 'asc')
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([
|
||||||
Toggle::make('is_admin')
|
Section::make('Profile')->columns(2)->schema([
|
||||||
->label('Admin')
|
TextInput::make('name')
|
||||||
->helperText('Grants access to this admin panel.'),
|
->required()
|
||||||
TextInput::make('postcode')
|
->maxLength(255),
|
||||||
->label('Postcode')
|
TextInput::make('email')
|
||||||
->maxLength(8),
|
->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')
|
||||||
|
->label('Admin')
|
||||||
|
->helperText('Grants access to this admin panel.'),
|
||||||
|
DateTimePicker::make('email_verified_at')
|
||||||
|
->label('Email verified at'),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Middleware/RequiresFeature.php
Normal file
30
app/Http/Middleware/RequiresFeature.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class RequiresFeature
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*
|
||||||
|
* @param Closure(Request): (Response) $next
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next, string $feature): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user || ! PlanFeatures::for($user)->can($feature)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'upgrade_required',
|
||||||
|
'feature' => $feature,
|
||||||
|
], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
99
app/Jobs/DispatchUserNotificationJob.php
Normal file
99
app/Jobs/DispatchUserNotificationJob.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserNotificationPreference;
|
||||||
|
use App\Notifications\FuelPriceAlert;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves allowed notification channels for a user and trigger, dispatches
|
||||||
|
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
|
||||||
|
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
|
||||||
|
*/
|
||||||
|
final class DispatchUserNotificationJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
private const array ALL_CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly User $user,
|
||||||
|
public readonly string $triggerType,
|
||||||
|
public readonly string $fuelType,
|
||||||
|
public readonly ?float $price = null,
|
||||||
|
) {
|
||||||
|
$this->onQueue('notifications');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$features = PlanFeatures::for($this->user);
|
||||||
|
|
||||||
|
// Step 3: channels that pass tier + user-pref + daily-limit checks
|
||||||
|
$allowed = $features->channelsFor($this->triggerType);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
$this->log($channel, sent: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels not in the allowed set — split into missed reasons
|
||||||
|
$notAllowed = array_diff(self::ALL_CHANNELS, $allowed);
|
||||||
|
|
||||||
|
foreach ($notAllowed as $channel) {
|
||||||
|
if (! $this->userHasEnabledPref($channel)) {
|
||||||
|
// User intentionally disabled — do not log (noise)
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($features->canUseChannel($channel)) {
|
||||||
|
// Step 5: tier allows but daily limit exhausted
|
||||||
|
$this->log($channel, sent: false, missedReason: 'daily_limit');
|
||||||
|
} else {
|
||||||
|
// Step 6: tier does not allow the channel the user wanted
|
||||||
|
$this->log($channel, sent: false, missedReason: 'tier_restricted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function log(string $channel, bool $sent, ?string $missedReason = null): void
|
||||||
|
{
|
||||||
|
NotificationLog::create([
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'channel' => $channel,
|
||||||
|
'trigger_type' => $this->triggerType,
|
||||||
|
'fuel_type' => $this->fuelType,
|
||||||
|
'price' => $this->price,
|
||||||
|
'sent' => $sent,
|
||||||
|
'missed_reason' => $missedReason,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userHasEnabledPref(string $channel): bool
|
||||||
|
{
|
||||||
|
return UserNotificationPreference::where('user_id', $this->user->id)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('enabled', true)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/Jobs/SendScheduledWhatsAppJob.php
Normal file
53
app/Jobs/SendScheduledWhatsAppJob.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserNotificationPreference;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fan-out job for scheduled WhatsApp updates (morning / evening).
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
final class SendScheduledWhatsAppJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(public readonly string $period)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
|
||||||
|
|
||||||
|
// Candidates: users who have explicitly opted in to WhatsApp.
|
||||||
|
// Per-user tier + daily-limit + scheduled-updates checks happen via
|
||||||
|
// canSendNow('whatsapp'); that single call covers tier eligibility
|
||||||
|
// (canUseChannel) AND today's notification_log count.
|
||||||
|
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
|
||||||
|
->where('enabled', true)
|
||||||
|
->distinct()
|
||||||
|
->pluck('user_id');
|
||||||
|
|
||||||
|
User::whereIn('id', $userIds)
|
||||||
|
->chunkById(500, function (Collection $users) use ($triggerType): void {
|
||||||
|
foreach ($users as $user) {
|
||||||
|
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,8 @@ use Database\Factories\BrentPriceFactory;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
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;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
#[Fillable(['date', 'price_usd'])]
|
#[Fillable(['date', 'price_usd', 'prediction_generated_at'])]
|
||||||
class BrentPrice extends Model
|
class BrentPrice extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<BrentPriceFactory> */
|
/** @use HasFactory<BrentPriceFactory> */
|
||||||
@@ -27,6 +26,7 @@ class BrentPrice extends Model
|
|||||||
return [
|
return [
|
||||||
'date' => 'date',
|
'date' => 'date',
|
||||||
'price_usd' => 'decimal:2',
|
'price_usd' => 'decimal:2',
|
||||||
|
'prediction_generated_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/Models/NotificationLog.php
Normal file
56
app/Models/NotificationLog.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Database\Factories\NotificationLogFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class NotificationLog extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<NotificationLogFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
protected $table = 'notification_log';
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'channel',
|
||||||
|
'trigger_type',
|
||||||
|
'fuel_type',
|
||||||
|
'price',
|
||||||
|
'sent',
|
||||||
|
'missed_reason',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count sent notifications for this channel today. */
|
||||||
|
public function scopeSentToday(Builder $query, string $channel): void
|
||||||
|
{
|
||||||
|
$query->where('channel', $channel)
|
||||||
|
->where('sent', true)
|
||||||
|
->whereDate('created_at', today());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Notifications that were not sent. */
|
||||||
|
public function scopeMissed(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('sent', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sent' => 'boolean',
|
||||||
|
'price' => 'decimal:3',
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
151
app/Models/Plan.php
Normal file
151
app/Models/Plan.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PlanTier;
|
||||||
|
use Database\Factories\PlanFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
class Plan extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<PlanFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'stripe_price_id_monthly',
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the active plan for a user.
|
||||||
|
* Falls back to the free plan when no active Cashier subscription exists.
|
||||||
|
*/
|
||||||
|
public static function resolveForUser(User $user): self
|
||||||
|
{
|
||||||
|
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
|
||||||
|
|
||||||
|
$planId = $cache->remember(
|
||||||
|
"plan_for_user_{$user->id}",
|
||||||
|
3600,
|
||||||
|
function () use ($user): ?int {
|
||||||
|
$priceId = null;
|
||||||
|
|
||||||
|
if (method_exists($user, 'subscriptions')) {
|
||||||
|
$subscription = $user->subscriptions()->active()->first();
|
||||||
|
$priceId = $subscription?->stripe_price ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($priceId) {
|
||||||
|
$plan = static::where(fn ($q) => $q
|
||||||
|
->where('stripe_price_id_monthly', $priceId)
|
||||||
|
->orWhere('stripe_price_id_annual', $priceId))
|
||||||
|
->where('active', true)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($plan) {
|
||||||
|
return $plan->id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::saved(function (): void {
|
||||||
|
if (Cache::supportsTags()) {
|
||||||
|
Cache::tags(['plans'])->flush();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Support\Carbon;
|
|
||||||
|
|
||||||
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
|
||||||
class PricePrediction extends Model
|
class PricePrediction extends Model
|
||||||
@@ -39,11 +38,17 @@ class PricePrediction extends Model
|
|||||||
*/
|
*/
|
||||||
public function scopeBestFirst(Builder $query): Builder
|
public function scopeBestFirst(Builder $query): Builder
|
||||||
{
|
{
|
||||||
$priority = implode(', ', array_map(
|
$priority = [
|
||||||
fn (string $v) => "'$v'",
|
PredictionSource::LlmWithContext->value,
|
||||||
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
|
PredictionSource::Llm->value,
|
||||||
));
|
PredictionSource::Ewma->value,
|
||||||
|
];
|
||||||
|
|
||||||
return $query->orderByRaw("FIELD(source, $priority)");
|
$cases = '';
|
||||||
|
foreach ($priority as $rank => $source) {
|
||||||
|
$cases .= " WHEN '$source' THEN $rank";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,15 +10,17 @@ 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
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'fuel_type' => FuelType::class,
|
'fuel_type' => FuelType::class,
|
||||||
'price_effective_at' => 'datetime',
|
'price_effective_at' => 'datetime',
|
||||||
'price_reported_at' => 'datetime',
|
'price_reported_at' => 'datetime',
|
||||||
'recorded_at' => 'datetime',
|
'recorded_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +60,14 @@ class User extends Authenticatable implements FilamentUser
|
|||||||
{
|
{
|
||||||
return $this->hasMany(SavedStation::class);
|
return $this->hasMany(SavedStation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function notificationPreferences(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserNotificationPreference::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function notificationLogs(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(NotificationLog::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
app/Models/UserNotificationPreference.php
Normal file
51
app/Models/UserNotificationPreference.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use Database\Factories\UserNotificationPreferenceFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class UserNotificationPreference extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<UserNotificationPreferenceFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'channel',
|
||||||
|
'fuel_type',
|
||||||
|
'enabled',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeEnabled(Builder $query): void
|
||||||
|
{
|
||||||
|
$query->where('enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForChannel(Builder $query, string $channel): void
|
||||||
|
{
|
||||||
|
$query->where('channel', $channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForFuelType(Builder $query, string $fuelType): void
|
||||||
|
{
|
||||||
|
$query->where('fuel_type', $fuelType);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => 'boolean',
|
||||||
|
'fuel_type' => FuelType::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
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,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Listeners\HandleStripeWebhook;
|
||||||
|
use App\Models\Subscription;
|
||||||
use App\Services\ApiLogger;
|
use App\Services\ApiLogger;
|
||||||
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
use App\Services\LlmPrediction\AnthropicPredictionProvider;
|
||||||
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
use App\Services\LlmPrediction\GeminiPredictionProvider;
|
||||||
@@ -10,8 +12,11 @@ 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
|
||||||
{
|
{
|
||||||
@@ -37,6 +42,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 +59,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()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Models\ApiLog;
|
use App\Models\ApiLog;
|
||||||
use Illuminate\Http\Client\Response;
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ApiLogger
|
class ApiLogger
|
||||||
@@ -26,6 +27,10 @@ class ApiLogger
|
|||||||
$response = $request();
|
$response = $request();
|
||||||
$statusCode = $response->status();
|
$statusCode = $response->status();
|
||||||
|
|
||||||
|
if ($response->failed()) {
|
||||||
|
$error = Str::limit($response->body(), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$error = $e->getMessage();
|
$error = $e->getMessage();
|
||||||
|
|||||||
44
app/Services/BrentPriceFetcher.php
Normal file
44
app/Services/BrentPriceFetcher.php
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\BrentPrice;
|
||||||
|
use App\Services\BrentPriceSources\BrentPriceFetchException;
|
||||||
|
use App\Services\BrentPriceSources\EiaBrentPriceSource;
|
||||||
|
use App\Services\BrentPriceSources\FredBrentPriceSource;
|
||||||
|
|
||||||
|
final readonly class BrentPriceFetcher
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly EiaBrentPriceSource $eia,
|
||||||
|
private readonly FredBrentPriceSource $fred,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch from EIA and persist. Throws on failure.
|
||||||
|
*/
|
||||||
|
public function fetchFromEia(): void
|
||||||
|
{
|
||||||
|
$rows = $this->eia->fetch();
|
||||||
|
|
||||||
|
if ($rows === null) {
|
||||||
|
throw new BrentPriceFetchException('EIA fetch returned no data');
|
||||||
|
}
|
||||||
|
|
||||||
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch from FRED and persist. Throws on failure.
|
||||||
|
*/
|
||||||
|
public function fetchFromFred(): void
|
||||||
|
{
|
||||||
|
$rows = $this->fred->fetch();
|
||||||
|
|
||||||
|
if ($rows === null) {
|
||||||
|
throw new BrentPriceFetchException('FRED fetch returned no data');
|
||||||
|
}
|
||||||
|
|
||||||
|
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/Services/BrentPricePredictor.php
Normal file
119
app/Services/BrentPricePredictor.php
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<?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_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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try LLM first; persist EWMA only as a fallback when the LLM provider
|
||||||
|
* returns null. The downstream OilSignal already prefers LLM
|
||||||
|
* (llm_with_context > llm > ewma), so writing both rows on every run is
|
||||||
|
* dead weight 95% of the time. EWMA still acts as the safety net.
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$llm = $this->provider->predict($prices);
|
||||||
|
|
||||||
|
if ($llm !== null) {
|
||||||
|
PricePrediction::create($llm->toArray());
|
||||||
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||||
|
|
||||||
|
return $llm;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ewma = $this->generateEwmaPrediction($prices);
|
||||||
|
|
||||||
|
if ($ewma !== null) {
|
||||||
|
PricePrediction::create($ewma->toArray());
|
||||||
|
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $ewma;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = Ewma::compute(array_slice($chronological, -3));
|
||||||
|
$ewma7 = Ewma::compute(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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ewmaConfidence(float $changePct): int
|
||||||
|
{
|
||||||
|
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
||||||
|
|
||||||
|
return (int) round(max(30, $scaled));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\BrentPriceSources;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class BrentPriceFetchException extends RuntimeException {}
|
||||||
59
app/Services/BrentPriceSources/EiaBrentPriceSource.php
Normal file
59
app/Services/BrentPriceSources/EiaBrentPriceSource.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\BrentPriceSources;
|
||||||
|
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class EiaBrentPriceSource
|
||||||
|
{
|
||||||
|
private const string URL = 'https://api.eia.gov/v2/petroleum/pri/spt/data/';
|
||||||
|
|
||||||
|
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$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, [
|
||||||
|
'api_key' => config('services.eia.api_key'),
|
||||||
|
'frequency' => 'daily',
|
||||||
|
'data[0]' => 'value',
|
||||||
|
'facets[series][]' => 'RBRTE',
|
||||||
|
'sort[0][column]' => 'period',
|
||||||
|
'sort[0][direction]' => 'desc',
|
||||||
|
'length' => 30,
|
||||||
|
]));
|
||||||
|
} catch (ConnectionException $e) {
|
||||||
|
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
|
||||||
|
} catch (RequestException $e) {
|
||||||
|
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = collect($response->json('response.data') ?? [])
|
||||||
|
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
|
||||||
|
->map(fn (array $row) => [
|
||||||
|
'date' => $row['period'],
|
||||||
|
'price_usd' => (float) $row['value'],
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $rows === [] ? null : $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRetry(Throwable $e): bool
|
||||||
|
{
|
||||||
|
return $e instanceof ConnectionException
|
||||||
|
|| ($e instanceof RequestException && $e->response->serverError());
|
||||||
|
}
|
||||||
|
}
|
||||||
57
app/Services/BrentPriceSources/FredBrentPriceSource.php
Normal file
57
app/Services/BrentPriceSources/FredBrentPriceSource.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\BrentPriceSources;
|
||||||
|
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\RequestException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final class FredBrentPriceSource
|
||||||
|
{
|
||||||
|
private const string URL = 'https://api.stlouisfed.org/fred/series/observations';
|
||||||
|
|
||||||
|
public function __construct(private readonly ApiLogger $apiLogger) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
|
||||||
|
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
|
||||||
|
->throw()
|
||||||
|
->get(self::URL, [
|
||||||
|
'series_id' => 'DCOILBRENTEU',
|
||||||
|
'api_key' => config('services.fred.api_key'),
|
||||||
|
'sort_order' => 'desc',
|
||||||
|
'limit' => 30,
|
||||||
|
'file_type' => 'json',
|
||||||
|
]));
|
||||||
|
} 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') ?? [])
|
||||||
|
->filter(fn (array $obs) => $obs['value'] !== '.')
|
||||||
|
->map(fn (array $obs) => [
|
||||||
|
'date' => $obs['date'],
|
||||||
|
'price_usd' => (float) $obs['value'],
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $rows === [] ? null : $rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function shouldRetry(Throwable $e): bool
|
||||||
|
{
|
||||||
|
return $e instanceof ConnectionException
|
||||||
|
|| ($e instanceof RequestException && $e->response->serverError());
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Services/Ewma.php
Normal file
25
app/Services/Ewma.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exponentially-weighted moving average. Pure function — used by
|
||||||
|
* BrentPricePredictor for the EWMA fallback prediction and by
|
||||||
|
* AnthropicPredictionProvider to enrich the basic-flow prompt.
|
||||||
|
*/
|
||||||
|
final class Ewma
|
||||||
|
{
|
||||||
|
public const float DEFAULT_ALPHA = 0.3;
|
||||||
|
|
||||||
|
/** @param float[] $prices Chronological order (oldest first). */
|
||||||
|
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
|
||||||
|
{
|
||||||
|
$ema = $prices[0];
|
||||||
|
|
||||||
|
foreach (array_slice($prices, 1) as $price) {
|
||||||
|
$ema = $alpha * $price + (1 - $alpha) * $ema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($ema, 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use App\Enums\FuelType;
|
|||||||
use App\Models\Station;
|
use App\Models\Station;
|
||||||
use App\Models\StationPrice;
|
use App\Models\StationPrice;
|
||||||
use App\Models\StationPriceCurrent;
|
use App\Models\StationPriceCurrent;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
@@ -17,6 +18,8 @@ class FuelPriceService
|
|||||||
{
|
{
|
||||||
private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token';
|
||||||
|
|
||||||
|
private const string LAST_PRICE_POLL_CACHE_KEY = 'fuel_finder_last_price_poll_at';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-fuel-type valid price range in pence (as returned by the API).
|
* Per-fuel-type valid price range in pence (as returned by the API).
|
||||||
* Based on UK all-time records + 30–75% headroom for future spikes.
|
* Based on UK all-time records + 30–75% headroom for future spikes.
|
||||||
@@ -42,7 +45,8 @@ class FuelPriceService
|
|||||||
{
|
{
|
||||||
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
|
||||||
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
|
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10)
|
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::retry(3, 500)
|
||||||
|
->timeout(60)
|
||||||
->post($url, [
|
->post($url, [
|
||||||
'client_id' => config('services.fuel_finder.client_id'),
|
'client_id' => config('services.fuel_finder.client_id'),
|
||||||
'client_secret' => config('services.fuel_finder.client_secret'),
|
'client_secret' => config('services.fuel_finder.client_secret'),
|
||||||
@@ -55,51 +59,27 @@ class FuelPriceService
|
|||||||
/**
|
/**
|
||||||
* Poll the prices endpoint, deduplicate, and persist changes.
|
* Poll the prices endpoint, deduplicate, and persist changes.
|
||||||
*
|
*
|
||||||
|
* Uses incremental polling when a previous poll timestamp is cached — only
|
||||||
|
* stations with prices changed since then are returned by the API. Falls
|
||||||
|
* back to a full fetch on cold start (cache miss).
|
||||||
|
*
|
||||||
* @return int Number of new price records inserted
|
* @return int Number of new price records inserted
|
||||||
*/
|
*/
|
||||||
public function pollPrices(): int
|
public function pollPrices(): int
|
||||||
{
|
{
|
||||||
$token = $this->getAccessToken();
|
$pollStartedAt = now();
|
||||||
$inserted = 0;
|
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
|
||||||
$batch = 1;
|
$sinceCarbon = $since instanceof CarbonInterface ? $since : null;
|
||||||
|
|
||||||
do {
|
[$inserted, $completedCleanly] = $this->iterateBatches(
|
||||||
try {
|
'/pfs/fuel-prices',
|
||||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
|
$sinceCarbon,
|
||||||
$params = ['batch-number' => $batch];
|
fn (array $stations): int => $this->processPriceBatch($stations),
|
||||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
);
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
|
||||||
->withToken($token)
|
|
||||||
->get($baseUrl, $params));
|
|
||||||
|
|
||||||
if ($response->notFound()) {
|
if ($completedCleanly) {
|
||||||
break; // No more batches
|
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('FuelPriceService: price batch returned error', [
|
|
||||||
'batch' => $batch,
|
|
||||||
'status' => $response->status(),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$stations = $response->json() ?? [];
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('FuelPriceService: price batch fetch failed', [
|
|
||||||
'batch' => $batch,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($stations)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inserted += $this->processPriceBatch($stations);
|
|
||||||
$batch++;
|
|
||||||
} while (true);
|
|
||||||
|
|
||||||
return $inserted;
|
return $inserted;
|
||||||
}
|
}
|
||||||
@@ -109,25 +89,53 @@ class FuelPriceService
|
|||||||
* Called on full daily refresh before pollPrices().
|
* Called on full daily refresh before pollPrices().
|
||||||
*/
|
*/
|
||||||
public function refreshStations(): void
|
public function refreshStations(): void
|
||||||
|
{
|
||||||
|
$this->iterateBatches('/pfs', null, function (array $stations): int {
|
||||||
|
$this->upsertStations($stations);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive a paginated fuel-finder endpoint until exhausted, calling
|
||||||
|
* $process on each non-empty batch. Returns the sum of $process return
|
||||||
|
* values plus a flag indicating the loop exited cleanly (404 or empty
|
||||||
|
* body) rather than via an HTTP error or thrown exception. Callers use
|
||||||
|
* the flag to decide whether to update incremental-poll bookkeeping.
|
||||||
|
*
|
||||||
|
* @param callable(array<int, array<string, mixed>>): int $process
|
||||||
|
* @return array{0: int, 1: bool}
|
||||||
|
*/
|
||||||
|
private function iterateBatches(string $endpoint, ?CarbonInterface $since, callable $process): array
|
||||||
{
|
{
|
||||||
$token = $this->getAccessToken();
|
$token = $this->getAccessToken();
|
||||||
|
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
|
||||||
|
$total = 0;
|
||||||
$batch = 1;
|
$batch = 1;
|
||||||
|
$completedCleanly = false;
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try {
|
try {
|
||||||
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
|
|
||||||
$params = ['batch-number' => $batch];
|
$params = ['batch-number' => $batch];
|
||||||
|
|
||||||
|
if ($since !== null) {
|
||||||
|
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
|
||||||
|
}
|
||||||
|
|
||||||
$logUrl = $baseUrl.'?'.http_build_query($params);
|
$logUrl = $baseUrl.'?'.http_build_query($params);
|
||||||
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
|
||||||
->withToken($token)
|
->withToken($token)
|
||||||
->get($baseUrl, $params));
|
->get($baseUrl, $params));
|
||||||
|
|
||||||
if ($response->notFound()) {
|
if ($response->notFound()) {
|
||||||
break; // No more batches
|
$completedCleanly = true;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('FuelPriceService: station batch returned error', [
|
Log::error('FuelPriceService: batch returned error', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
'batch' => $batch,
|
'batch' => $batch,
|
||||||
'status' => $response->status(),
|
'status' => $response->status(),
|
||||||
]);
|
]);
|
||||||
@@ -136,7 +144,8 @@ class FuelPriceService
|
|||||||
|
|
||||||
$stations = $response->json() ?? [];
|
$stations = $response->json() ?? [];
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('FuelPriceService: station batch fetch failed', [
|
Log::error('FuelPriceService: batch fetch failed', [
|
||||||
|
'endpoint' => $endpoint,
|
||||||
'batch' => $batch,
|
'batch' => $batch,
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
@@ -144,12 +153,15 @@ class FuelPriceService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (empty($stations)) {
|
if (empty($stations)) {
|
||||||
|
$completedCleanly = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->upsertStations($stations);
|
$total += $process($stations);
|
||||||
$batch++;
|
$batch++;
|
||||||
} while (true);
|
} while (true);
|
||||||
|
|
||||||
|
return [$total, $completedCleanly];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<int, array<string, mixed>> $apiStations */
|
/** @param array<int, array<string, mixed>> $apiStations */
|
||||||
@@ -159,6 +171,14 @@ class FuelPriceService
|
|||||||
$rows = [];
|
$rows = [];
|
||||||
|
|
||||||
foreach ($apiStations as $data) {
|
foreach ($apiStations as $data) {
|
||||||
|
if (! $this->hasRequiredStationFields($data)) {
|
||||||
|
Log::warning('FuelPriceService: station skipped — missing required fields', [
|
||||||
|
'node_id' => $data['node_id'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$station = new Station([
|
$station = new Station([
|
||||||
'node_id' => $data['node_id'],
|
'node_id' => $data['node_id'],
|
||||||
'trading_name' => $data['trading_name'],
|
'trading_name' => $data['trading_name'],
|
||||||
@@ -179,9 +199,9 @@ class FuelPriceService
|
|||||||
'postcode' => $data['location']['postcode'],
|
'postcode' => $data['location']['postcode'],
|
||||||
'lat' => $data['location']['latitude'],
|
'lat' => $data['location']['latitude'],
|
||||||
'lng' => $data['location']['longitude'],
|
'lng' => $data['location']['longitude'],
|
||||||
'amenities' => $data['amenities'] ?? [],
|
'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
|
||||||
'opening_times' => $data['opening_times'] ?? null,
|
'opening_times' => $data['opening_times'] ?? null,
|
||||||
'fuel_types' => $data['fuel_types'] ?? [],
|
'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
|
||||||
'last_seen_at' => $now,
|
'last_seen_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -189,7 +209,40 @@ class FuelPriceService
|
|||||||
$rows[] = $station->getAttributes();
|
$rows[] = $station->getAttributes();
|
||||||
}
|
}
|
||||||
|
|
||||||
Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? []));
|
if ($rows === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Station::upsert($rows, ['node_id'], array_keys($rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function hasRequiredStationFields(array $data): bool
|
||||||
|
{
|
||||||
|
return ! empty($data['node_id'])
|
||||||
|
&& ! empty($data['trading_name'])
|
||||||
|
&& isset($data['location']['postcode'], $data['location']['latitude'], $data['location']['longitude']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API returns `amenities` and `fuel_types` as objects with boolean
|
||||||
|
* flags (e.g. {"E10": true, "car_wash": false}). Flatten to a list of
|
||||||
|
* enabled keys. If the payload is already an array of strings, return as-is.
|
||||||
|
*
|
||||||
|
* @param array<string, bool>|array<int, string> $flags
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
private function flattenEnabledFlags(array $flags): array
|
||||||
|
{
|
||||||
|
if ($flags === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (array_is_list($flags)) {
|
||||||
|
return array_values($flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_keys(array_filter($flags, fn ($v) => filter_var($v, FILTER_VALIDATE_BOOLEAN))));
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isValidPrice(FuelType $fuelType, float $pricePence): bool
|
private function isValidPrice(FuelType $fuelType, float $pricePence): bool
|
||||||
@@ -216,8 +269,22 @@ class FuelPriceService
|
|||||||
{
|
{
|
||||||
$stationIds = array_column($apiBatch, 'node_id');
|
$stationIds = array_column($apiBatch, 'node_id');
|
||||||
|
|
||||||
|
// Filter to stations that exist in the stations table — prevents FK
|
||||||
|
// violations when the API surfaces a station before the next metadata
|
||||||
|
// refresh picks it up.
|
||||||
|
$knownStationIds = array_flip(
|
||||||
|
Station::whereIn('node_id', $stationIds)->pluck('node_id')->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$unknown = array_diff($stationIds, array_keys($knownStationIds));
|
||||||
|
if ($unknown !== []) {
|
||||||
|
Log::info('FuelPriceService: skipped prices for unknown stations', [
|
||||||
|
'count' => count($unknown),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Load current prices for all stations in this batch in one query
|
// Load current prices for all stations in this batch in one query
|
||||||
$currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds)
|
$currentPrices = StationPriceCurrent::whereIn('station_id', array_keys($knownStationIds))
|
||||||
->get()
|
->get()
|
||||||
->groupBy('station_id')
|
->groupBy('station_id')
|
||||||
->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value));
|
->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value));
|
||||||
@@ -227,9 +294,17 @@ class FuelPriceService
|
|||||||
$upsertRows = [];
|
$upsertRows = [];
|
||||||
|
|
||||||
foreach ($apiBatch as $station) {
|
foreach ($apiBatch as $station) {
|
||||||
$stationId = $station['node_id'];
|
$stationId = $station['node_id'] ?? null;
|
||||||
|
|
||||||
|
if ($stationId === null || ! isset($knownStationIds[$stationId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($station['fuel_prices'] ?? [] as $priceData) {
|
foreach ($station['fuel_prices'] ?? [] as $priceData) {
|
||||||
|
if (! isset($priceData['fuel_type'], $priceData['price'], $priceData['price_last_updated'], $priceData['price_change_effective_timestamp'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$fuelType = FuelType::fromApiValue($priceData['fuel_type']);
|
$fuelType = FuelType::fromApiValue($priceData['fuel_type']);
|
||||||
} catch (ValueError) {
|
} catch (ValueError) {
|
||||||
|
|||||||
41
app/Services/HaversineQuery.php
Normal file
41
app/Services/HaversineQuery.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds canonical haversine SQL fragments for distance and within-radius
|
||||||
|
* filtering. Centralises the float-clamping (GREATEST/LEAST) and the column
|
||||||
|
* naming convention used across prediction and station search queries.
|
||||||
|
*
|
||||||
|
* Assumes the joined/queried table exposes columns `lat` and `lng`.
|
||||||
|
*/
|
||||||
|
final class HaversineQuery
|
||||||
|
{
|
||||||
|
private const string DISTANCE_KM_SQL =
|
||||||
|
'(6371 * acos(GREATEST(-1.0, LEAST(1.0, '
|
||||||
|
.'cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) '
|
||||||
|
.'+ sin(radians(?)) * sin(radians(lat))))))';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare distance-in-km expression. Caller adds aliasing or comparison.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: array{float, float, float}}
|
||||||
|
*/
|
||||||
|
public static function distanceKm(float $lat, float $lng): array
|
||||||
|
{
|
||||||
|
return [self::DISTANCE_KM_SQL, [$lat, $lng, $lat]];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<= {km}` predicate suitable for whereRaw. The radius is embedded as a
|
||||||
|
* numeric literal because PDO + SQLite's whereRaw binds floats as strings
|
||||||
|
* by default, which breaks numeric comparison against the haversine
|
||||||
|
* expression. The `float` parameter is type-checked and not user input.
|
||||||
|
*
|
||||||
|
* @return array{0: string, 1: array{float, float, float}}
|
||||||
|
*/
|
||||||
|
public static function withinKm(float $lat, float $lng, float $km): array
|
||||||
|
{
|
||||||
|
return [self::DISTANCE_KM_SQL.' <= '.sprintf('%F', $km), [$lat, $lng, $lat]];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
99
app/Services/LlmPrediction/AbstractLlmPredictionProvider.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
|
use App\Enums\PredictionSource;
|
||||||
|
use App\Enums\TrendDirection;
|
||||||
|
use App\Models\BrentPrice;
|
||||||
|
use App\Models\PricePrediction;
|
||||||
|
use App\Services\ApiLogger;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
|
||||||
|
{
|
||||||
|
protected const int LLM_MAX_CONFIDENCE = 85;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected readonly ApiLogger $apiLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default flow: gate on API key, call the provider, normalise the payload
|
||||||
|
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
|
||||||
|
* web-search) override `predict()` directly and reuse the helper methods.
|
||||||
|
*/
|
||||||
|
public function predict(Collection $prices): ?PricePrediction
|
||||||
|
{
|
||||||
|
$apiKey = $this->apiKey();
|
||||||
|
|
||||||
|
if ($apiKey === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
|
||||||
|
|
||||||
|
return $payload === null ? null : $this->buildPrediction($payload);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns the configured API key or null if not set. */
|
||||||
|
abstract protected function apiKey(): ?string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make the provider HTTP call and return the normalised payload, or null
|
||||||
|
* on failure (already logged by the implementer).
|
||||||
|
*
|
||||||
|
* @return array{direction: string, confidence: int, reasoning: string}|null
|
||||||
|
*/
|
||||||
|
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
|
||||||
|
|
||||||
|
/** @param Collection<int, BrentPrice> $prices */
|
||||||
|
protected function buildPriceList(Collection $prices): string
|
||||||
|
{
|
||||||
|
return $prices->sortBy('date')
|
||||||
|
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
||||||
|
->implode("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
||||||
|
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
|
||||||
|
{
|
||||||
|
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
||||||
|
|
||||||
|
if ($direction === null) {
|
||||||
|
Log::error(static::class.': invalid direction', ['input' => $input]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new PricePrediction([
|
||||||
|
'predicted_for' => now()->toDateString(),
|
||||||
|
'source' => $source,
|
||||||
|
'direction' => $direction,
|
||||||
|
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
|
||||||
|
'reasoning' => $input['reasoning'] ?? '',
|
||||||
|
'generated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function defaultPrompt(string $priceList): string
|
||||||
|
{
|
||||||
|
return <<<PROMPT
|
||||||
|
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||||
|
Predict the short-term direction over the next 3–5 days.
|
||||||
|
|
||||||
|
Recent Brent crude prices (USD/barrel):
|
||||||
|
{$priceList}
|
||||||
|
|
||||||
|
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
||||||
|
and a one-sentence reasoning.
|
||||||
|
PROMPT;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,31 +3,23 @@
|
|||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
use App\Enums\PredictionSource;
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
use App\Models\PricePrediction;
|
||||||
use App\Services\ApiLogger;
|
use App\Services\Ewma;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class AnthropicPredictionProvider implements OilPredictionProvider
|
class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
|
||||||
|
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
* Tries web-search-enriched prediction first, falls back to basic tool use.
|
||||||
|
* Overrides the parent flow because Anthropic uses two phases (web search
|
||||||
|
* loop + forced tool call) and selects the source dynamically.
|
||||||
*/
|
*/
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
public function predict(Collection $prices): ?PricePrediction
|
||||||
{
|
{
|
||||||
if (! config('services.anthropic.api_key')) {
|
if ($this->apiKey() === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
return $prediction ?? $this->predictBasic($prices);
|
return $prediction ?? $this->predictBasic($prices);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function apiKey(): ?string
|
||||||
|
{
|
||||||
|
return config('services.anthropic.api_key');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@inheritDoc} */
|
||||||
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-turn web search phase, then a forced submit_prediction call.
|
* Multi-turn web search phase, then a forced submit_prediction call.
|
||||||
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
|
* Phase 1: let the model search for recent oil/geopolitical news.
|
||||||
* Phase 2: Force submit_prediction with the full conversation context.
|
* Phase 2: force submit_prediction with the full conversation context.
|
||||||
*/
|
*/
|
||||||
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
private function predictWithWebContext(Collection $prices): ?PricePrediction
|
||||||
{
|
{
|
||||||
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
$url = 'https://api.anthropic.com/v1/messages';
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Phase 1: web search loop
|
|
||||||
for ($i = 0, $response = null; $i < 5; $i++) {
|
for ($i = 0, $response = null; $i < 5; $i++) {
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
|
||||||
->withHeaders($this->headers())
|
->withHeaders($this->headers())
|
||||||
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]);
|
Log::error(self::class.': context search request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 2: forced submit with full context
|
|
||||||
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
|
||||||
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
|
||||||
|
|
||||||
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
]));
|
]));
|
||||||
|
|
||||||
if (! $submitResponse->successful()) {
|
if (! $submitResponse->successful()) {
|
||||||
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]);
|
Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
|
||||||
|
|
||||||
if ($input === null) {
|
return $input === null
|
||||||
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
|
? null
|
||||||
|
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-turn prediction using a forced submit_prediction tool call.
|
||||||
|
* Guarantees structured output — no JSON parsing needed.
|
||||||
|
*/
|
||||||
|
private function predictBasic(Collection $prices): ?PricePrediction
|
||||||
|
{
|
||||||
|
$chronological = $prices->sortBy('date');
|
||||||
|
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
|
||||||
|
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
|
||||||
|
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
|
||||||
|
|
||||||
|
$url = 'https://api.anthropic.com/v1/messages';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
||||||
|
->withHeaders($this->headers())
|
||||||
|
->post($url, [
|
||||||
|
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
||||||
|
'max_tokens' => 256,
|
||||||
|
'tools' => [$this->submitPredictionTool()],
|
||||||
|
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
||||||
|
'messages' => [[
|
||||||
|
'role' => 'user',
|
||||||
|
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
|
||||||
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->buildPrediction($input, PredictionSource::LlmWithContext);
|
$input = $this->extractToolInput($response->json('content') ?? []);
|
||||||
|
|
||||||
|
return $input === null ? null : $this->buildPrediction($input);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
|
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
PROMPT;
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildPriceList(Collection $prices): string
|
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
||||||
{
|
{
|
||||||
return $prices->sortBy('date')
|
return <<<PROMPT
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
||||||
->implode("\n");
|
Predict the short-term direction over the next 3–5 days.
|
||||||
|
|
||||||
|
Recent Brent crude prices (USD/barrel):
|
||||||
|
{$priceList}
|
||||||
|
|
||||||
|
Pre-computed indicators:
|
||||||
|
- 3-day EWMA: \${$ewma3}
|
||||||
|
- 7-day EWMA: \${$ewma7}
|
||||||
|
- 14-day EWMA: \${$ewma14}
|
||||||
|
|
||||||
|
Use the submit_prediction tool to submit your answer.
|
||||||
|
PROMPT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, string> */
|
/** @return array<string, string> */
|
||||||
private function headers(): array
|
private function headers(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'x-api-key' => config('services.anthropic.api_key'),
|
'x-api-key' => $this->apiKey(),
|
||||||
'anthropic-version' => '2023-06-01',
|
'anthropic-version' => '2023-06-01',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
|
|||||||
|
|
||||||
return $block['input'] ?? null;
|
return $block['input'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array{direction: string, confidence: int, reasoning: string} $input */
|
|
||||||
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
|
|
||||||
{
|
|
||||||
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
|
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => $source,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $input['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Single-turn prediction using a forced submit_prediction tool call.
|
|
||||||
* Guarantees structured output — no JSON parsing needed.
|
|
||||||
*/
|
|
||||||
private function predictBasic(Collection $prices): ?PricePrediction
|
|
||||||
{
|
|
||||||
$chronological = $prices->sortBy('date');
|
|
||||||
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
|
|
||||||
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
|
|
||||||
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
|
|
||||||
|
|
||||||
$priceList = $this->buildPriceList($prices);
|
|
||||||
|
|
||||||
$url = 'https://api.anthropic.com/v1/messages';
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
|
|
||||||
->withHeaders($this->headers())
|
|
||||||
->post($url, [
|
|
||||||
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
|
|
||||||
'max_tokens' => 256,
|
|
||||||
'tools' => [$this->submitPredictionTool()],
|
|
||||||
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
|
|
||||||
'messages' => [[
|
|
||||||
'role' => 'user',
|
|
||||||
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
|
|
||||||
]],
|
|
||||||
]));
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$input = $this->extractToolInput($response->json('content') ?? []);
|
|
||||||
|
|
||||||
if ($input === null) {
|
|
||||||
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->buildPrediction($input, PredictionSource::Llm);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param float[] $prices Chronological order (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 basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
|
|
||||||
{
|
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
|
||||||
{$priceList}
|
|
||||||
|
|
||||||
Pre-computed indicators:
|
|
||||||
- 3-day EWMA: \${$ewma3}
|
|
||||||
- 7-day EWMA: \${$ewma7}
|
|
||||||
- 14-day EWMA: \${$ewma14}
|
|
||||||
|
|
||||||
Use the submit_prediction tool to submit your answer.
|
|
||||||
PROMPT;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,110 +2,59 @@
|
|||||||
|
|
||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Services\ApiLogger;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class GeminiPredictionProvider implements OilPredictionProvider
|
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
protected function apiKey(): ?string
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
|
||||||
{
|
{
|
||||||
if (! config('services.gemini.api_key')) {
|
return config('services.gemini.api_key');
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$priceList = $prices->sortBy('date')
|
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
|
{
|
||||||
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
$model = config('services.gemini.model', 'gemini-2.0-flash');
|
||||||
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
||||||
|
|
||||||
try {
|
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
||||||
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
|
->withQueryParameters(['key' => $apiKey])
|
||||||
->withQueryParameters(['key' => config('services.gemini.api_key')])
|
->post($url, [
|
||||||
->post($url, [
|
'contents' => [[
|
||||||
'contents' => [[
|
'parts' => [['text' => $this->defaultPrompt($priceList)]],
|
||||||
'parts' => [['text' => $this->prompt($priceList)]],
|
]],
|
||||||
]],
|
'generationConfig' => [
|
||||||
'generationConfig' => [
|
'responseMimeType' => 'application/json',
|
||||||
'responseMimeType' => 'application/json',
|
'responseSchema' => [
|
||||||
'responseSchema' => [
|
'type' => 'OBJECT',
|
||||||
'type' => 'OBJECT',
|
'properties' => [
|
||||||
'properties' => [
|
'direction' => [
|
||||||
'direction' => [
|
'type' => 'STRING',
|
||||||
'type' => 'STRING',
|
'enum' => ['rising', 'falling', 'flat'],
|
||||||
'enum' => ['rising', 'falling', 'flat'],
|
|
||||||
],
|
|
||||||
'confidence' => ['type' => 'INTEGER'],
|
|
||||||
'reasoning' => ['type' => 'STRING'],
|
|
||||||
],
|
],
|
||||||
'required' => ['direction', 'confidence', 'reasoning'],
|
'confidence' => ['type' => 'INTEGER'],
|
||||||
|
'reasoning' => ['type' => 'STRING'],
|
||||||
],
|
],
|
||||||
|
'required' => ['direction', 'confidence', 'reasoning'],
|
||||||
],
|
],
|
||||||
]));
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]);
|
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
|
|
||||||
$data = json_decode($text, true);
|
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
|
||||||
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = TrendDirection::tryFrom($data['direction']);
|
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => PredictionSource::Llm,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $data['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function prompt(string $priceList): string
|
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
|
||||||
{
|
$data = json_decode($text, true);
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
{$priceList}
|
Log::error(self::class.': unexpected response format', ['text' => $text]);
|
||||||
|
|
||||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
return null;
|
||||||
and a one-sentence reasoning.
|
}
|
||||||
PROMPT;
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,112 +2,61 @@
|
|||||||
|
|
||||||
namespace App\Services\LlmPrediction;
|
namespace App\Services\LlmPrediction;
|
||||||
|
|
||||||
use App\Enums\PredictionSource;
|
|
||||||
use App\Enums\TrendDirection;
|
|
||||||
use App\Models\BrentPrice;
|
|
||||||
use App\Models\PricePrediction;
|
|
||||||
use App\Services\ApiLogger;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class OpenAiPredictionProvider implements OilPredictionProvider
|
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
|
||||||
{
|
{
|
||||||
private const int LLM_MAX_CONFIDENCE = 85;
|
protected function apiKey(): ?string
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function predict(Collection $prices): ?PricePrediction
|
|
||||||
{
|
{
|
||||||
if (! config('services.openai.api_key')) {
|
return config('services.openai.api_key');
|
||||||
return null;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$priceList = $prices->sortBy('date')
|
|
||||||
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
|
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
|
protected function callProvider(string $apiKey, string $priceList): ?array
|
||||||
|
{
|
||||||
$url = 'https://api.openai.com/v1/chat/completions';
|
$url = 'https://api.openai.com/v1/chat/completions';
|
||||||
|
|
||||||
try {
|
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
||||||
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
|
->withToken($apiKey)
|
||||||
->withToken(config('services.openai.api_key'))
|
->post($url, [
|
||||||
->post($url, [
|
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
||||||
'model' => config('services.openai.model', 'gpt-4o-mini'),
|
'response_format' => [
|
||||||
'response_format' => [
|
'type' => 'json_schema',
|
||||||
'type' => 'json_schema',
|
'json_schema' => [
|
||||||
'json_schema' => [
|
'name' => 'oil_prediction',
|
||||||
'name' => 'oil_prediction',
|
'strict' => true,
|
||||||
'strict' => true,
|
'schema' => [
|
||||||
'schema' => [
|
'type' => 'object',
|
||||||
'type' => 'object',
|
'properties' => [
|
||||||
'properties' => [
|
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
||||||
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
|
'confidence' => ['type' => 'integer'],
|
||||||
'confidence' => ['type' => 'integer'],
|
'reasoning' => ['type' => 'string'],
|
||||||
'reasoning' => ['type' => 'string'],
|
|
||||||
],
|
|
||||||
'required' => ['direction', 'confidence', 'reasoning'],
|
|
||||||
'additionalProperties' => false,
|
|
||||||
],
|
],
|
||||||
|
'required' => ['direction', 'confidence', 'reasoning'],
|
||||||
|
'additionalProperties' => false,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
'messages' => [[
|
],
|
||||||
'role' => 'user',
|
'messages' => [[
|
||||||
'content' => $this->prompt($priceList),
|
'role' => 'user',
|
||||||
]],
|
'content' => $this->defaultPrompt($priceList),
|
||||||
]));
|
]],
|
||||||
|
]));
|
||||||
|
|
||||||
if (! $response->successful()) {
|
if (! $response->successful()) {
|
||||||
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]);
|
Log::error(self::class.': request failed', ['status' => $response->status()]);
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
|
||||||
|
|
||||||
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
|
||||||
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = TrendDirection::tryFrom($data['direction']);
|
|
||||||
|
|
||||||
if ($direction === null) {
|
|
||||||
Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PricePrediction([
|
|
||||||
'predicted_for' => now()->toDateString(),
|
|
||||||
'source' => PredictionSource::Llm,
|
|
||||||
'direction' => $direction,
|
|
||||||
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
|
|
||||||
'reasoning' => $data['reasoning'],
|
|
||||||
'generated_at' => now(),
|
|
||||||
]);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private function prompt(string $priceList): string
|
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
|
||||||
{
|
|
||||||
return <<<PROMPT
|
|
||||||
You are analyzing Brent crude oil price data for a UK fuel price alert service.
|
|
||||||
Predict the short-term direction over the next 3–5 days.
|
|
||||||
|
|
||||||
Recent Brent crude prices (USD/barrel):
|
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
|
||||||
{$priceList}
|
Log::error(self::class.': unexpected response format', ['data' => $data]);
|
||||||
|
|
||||||
Respond with direction (rising, falling, or flat), a confidence score (0–85),
|
return null;
|
||||||
and a one-sentence reasoning.
|
}
|
||||||
PROMPT;
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,31 @@ namespace App\Services;
|
|||||||
|
|
||||||
use App\Enums\FuelType;
|
use App\Enums\FuelType;
|
||||||
use App\Models\StationPriceCurrent;
|
use App\Models\StationPriceCurrent;
|
||||||
|
use App\Services\Prediction\Signals\BrandBehaviourSignal;
|
||||||
|
use App\Services\Prediction\Signals\DayOfWeekSignal;
|
||||||
|
use App\Services\Prediction\Signals\OilSignal;
|
||||||
|
use App\Services\Prediction\Signals\RegionalMomentumSignal;
|
||||||
|
use App\Services\Prediction\Signals\SignalContext;
|
||||||
|
use App\Services\Prediction\Signals\StickinessSignal;
|
||||||
|
use App\Services\Prediction\Signals\TrendSignal;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class NationalFuelPredictionService
|
class NationalFuelPredictionService
|
||||||
{
|
{
|
||||||
private const float R_SQUARED_THRESHOLD = 0.5;
|
|
||||||
|
|
||||||
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
private const int PREDICTION_HORIZON_DAYS = 7;
|
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TrendSignal $trendSignal,
|
||||||
|
private readonly DayOfWeekSignal $dayOfWeekSignal,
|
||||||
|
private readonly BrandBehaviourSignal $brandBehaviourSignal,
|
||||||
|
private readonly StickinessSignal $stickinessSignal,
|
||||||
|
private readonly RegionalMomentumSignal $regionalMomentumSignal,
|
||||||
|
private readonly OilSignal $oilSignal,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* fuel_type: string,
|
* fuel_type: string,
|
||||||
@@ -34,19 +49,19 @@ class NationalFuelPredictionService
|
|||||||
{
|
{
|
||||||
$fuelType = FuelType::E10;
|
$fuelType = FuelType::E10;
|
||||||
$hasCoordinates = $lat !== null && $lng !== null;
|
$hasCoordinates = $lat !== null && $lng !== null;
|
||||||
|
$context = new SignalContext($fuelType, $lat, $lng);
|
||||||
|
|
||||||
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
|
||||||
$trend = $this->computeTrendSignal($fuelType);
|
$trend = $this->trendSignal->compute($context);
|
||||||
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
|
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
|
||||||
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
|
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
|
||||||
$stickiness = $this->computeStickinessSignal($fuelType);
|
$stickiness = $this->stickinessSignal->compute($context);
|
||||||
|
$oil = $this->oilSignal->compute($context);
|
||||||
|
|
||||||
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
|
||||||
$regionalMomentum = $hasCoordinates
|
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
|
||||||
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
|
|
||||||
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
|
||||||
|
|
||||||
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
|
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
|
||||||
|
|
||||||
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
|
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
|
||||||
|
|
||||||
@@ -65,6 +80,8 @@ class NationalFuelPredictionService
|
|||||||
default => 'no_signal',
|
default => 'no_signal',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'fuel_type' => $fuelType->value,
|
'fuel_type' => $fuelType->value,
|
||||||
'current_avg' => $currentAvg,
|
'current_avg' => $currentAvg,
|
||||||
@@ -73,10 +90,11 @@ class NationalFuelPredictionService
|
|||||||
'confidence_score' => $confidenceScore,
|
'confidence_score' => $confidenceScore,
|
||||||
'confidence_label' => $confidenceLabel,
|
'confidence_label' => $confidenceLabel,
|
||||||
'action' => $action,
|
'action' => $action,
|
||||||
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
|
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
|
||||||
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
|
||||||
'region_key' => $hasCoordinates ? 'regional' : 'national',
|
'region_key' => $hasCoordinates ? 'regional' : 'national',
|
||||||
'methodology' => 'multi_signal_live_fallback',
|
'methodology' => 'multi_signal_live_fallback',
|
||||||
|
'weekly_summary' => $weeklySummary,
|
||||||
'signals' => [
|
'signals' => [
|
||||||
'trend' => $trend,
|
'trend' => $trend,
|
||||||
'day_of_week' => $dayOfWeek,
|
'day_of_week' => $dayOfWeek,
|
||||||
@@ -84,6 +102,7 @@ class NationalFuelPredictionService
|
|||||||
'national_momentum' => $nationalMomentum,
|
'national_momentum' => $nationalMomentum,
|
||||||
'regional_momentum' => $regionalMomentum,
|
'regional_momentum' => $regionalMomentum,
|
||||||
'price_stickiness' => $stickiness,
|
'price_stickiness' => $stickiness,
|
||||||
|
'oil' => $oil,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -91,10 +110,12 @@ class NationalFuelPredictionService
|
|||||||
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
|
||||||
{
|
{
|
||||||
if ($lat !== null && $lng !== null) {
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
$avg = DB::table('station_prices_current')
|
$avg = DB::table('station_prices_current')
|
||||||
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
|
||||||
->where('station_prices_current.fuel_type', $fuelType->value)
|
->where('station_prices_current.fuel_type', $fuelType->value)
|
||||||
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat])
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
->avg('station_prices_current.price_pence');
|
->avg('station_prices_current.price_pence');
|
||||||
|
|
||||||
if ($avg !== null) {
|
if ($avg !== null) {
|
||||||
@@ -107,285 +128,6 @@ class NationalFuelPredictionService
|
|||||||
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Linear regression on daily national average prices.
|
|
||||||
* Tries 5-day lookback first; falls back to 14-day if R² < threshold.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float}
|
|
||||||
*/
|
|
||||||
private function computeTrendSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
foreach ([5, 14] as $lookbackDays) {
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
|
||||||
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
|
||||||
->groupBy('day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 2) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
|
||||||
|
|
||||||
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
|
||||||
$slope = $regression['slope'];
|
|
||||||
$direction = match (true) {
|
|
||||||
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
|
||||||
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
$absSlope = abs($slope);
|
|
||||||
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1);
|
|
||||||
$projected = round($slope * $lookbackDays, 1);
|
|
||||||
$detail = $direction === 'stable'
|
|
||||||
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
|
||||||
: sprintf(
|
|
||||||
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
|
||||||
$slope > 0 ? 'Rising' : 'Falling',
|
|
||||||
abs(round($slope, 2)),
|
|
||||||
$lookbackDays,
|
|
||||||
round($regression['r_squared'], 2),
|
|
||||||
$projected > 0 ? '+' : '',
|
|
||||||
$projected,
|
|
||||||
self::PREDICTION_HORIZON_DAYS,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($lookbackDays === 5) {
|
|
||||||
$detail .= ' [Adaptive lookback active]';
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $regression['r_squared']),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => $detail,
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
'slope' => round($slope, 3),
|
|
||||||
'r_squared' => round($regression['r_squared'], 3),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => 0.0,
|
|
||||||
'confidence' => 0.0,
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
|
||||||
'data_points' => 0,
|
|
||||||
'enabled' => false,
|
|
||||||
'slope' => 0.0,
|
|
||||||
'r_squared' => 0.0,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare today's average price against the per-weekday average over 90 days.
|
|
||||||
* Requires 56+ days of history to activate.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeDayOfWeekSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
|
||||||
$dowExpr = $isSqlite
|
|
||||||
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
|
|
||||||
: 'DAYOFWEEK(price_effective_at)';
|
|
||||||
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays(90))
|
|
||||||
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
|
||||||
->groupBy('dow', 'day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$uniqueDays = $rows->pluck('day')->unique()->count();
|
|
||||||
|
|
||||||
if ($uniqueDays < 56) {
|
|
||||||
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
|
|
||||||
}
|
|
||||||
|
|
||||||
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
|
||||||
$weekAvg = $dowAverages->avg();
|
|
||||||
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
|
||||||
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
|
||||||
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
|
||||||
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
||||||
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
|
|
||||||
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
|
|
||||||
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
|
||||||
|
|
||||||
$direction = match (true) {
|
|
||||||
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
|
||||||
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
|
|
||||||
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $uniqueDays / 90),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
|
|
||||||
'data_points' => $uniqueDays,
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare supermarket vs non-supermarket 7-day price trend.
|
|
||||||
* Detects divergence where one group has moved but the other hasn't yet.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeBrandBehaviourSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
|
||||||
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
|
||||||
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
|
||||||
->groupBy('stations.is_supermarket', 'day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
$supermarket = $rows->where('is_supermarket', 1)->values();
|
|
||||||
$major = $rows->where('is_supermarket', 0)->values();
|
|
||||||
|
|
||||||
if ($supermarket->count() < 2 || $major->count() < 2) {
|
|
||||||
return $this->disabledSignal('Insufficient brand data for comparison');
|
|
||||||
}
|
|
||||||
|
|
||||||
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
|
||||||
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
|
||||||
|
|
||||||
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
|
||||||
$supermarketChange = round($supermarketSlope * 7, 1);
|
|
||||||
$majorChange = round($majorSlope * 7, 1);
|
|
||||||
|
|
||||||
if ($divergence < 1.0) {
|
|
||||||
return [
|
|
||||||
'score' => 0.0,
|
|
||||||
'confidence' => 0.5,
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => 'Supermarkets and majors moving in sync.',
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
|
||||||
$direction = $leaderChange > 0 ? 'up' : 'down';
|
|
||||||
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
|
||||||
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
|
||||||
$leaderAbs = abs($leaderChange);
|
|
||||||
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $direction === 'up' ? 1.0 : -1.0,
|
|
||||||
'confidence' => min(1.0, $divergence / 5.0),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Average hold duration (days between price changes) as a confidence modifier.
|
|
||||||
* Requires 30+ days of history. Returns a score between -0.1 and +0.1.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeStickinessSignal(FuelType $fuelType): array
|
|
||||||
{
|
|
||||||
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
|
|
||||||
$diffExpr = $isSqlite
|
|
||||||
? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)'
|
|
||||||
: 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))';
|
|
||||||
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->where('fuel_type', $fuelType->value)
|
|
||||||
->where('price_effective_at', '>=', now()->subDays(30))
|
|
||||||
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
|
||||||
->groupBy('station_id')
|
|
||||||
->having('changes', '>', 1)
|
|
||||||
->having('span_days', '>', 0)
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 10) {
|
|
||||||
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
|
||||||
}
|
|
||||||
|
|
||||||
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
|
||||||
$avgHoldDays = round((float) $avgHoldDays, 1);
|
|
||||||
|
|
||||||
$score = match (true) {
|
|
||||||
$avgHoldDays < 2 => -0.1,
|
|
||||||
$avgHoldDays > 5 => 0.1,
|
|
||||||
default => 0.0,
|
|
||||||
};
|
|
||||||
|
|
||||||
$detail = match (true) {
|
|
||||||
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
|
||||||
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
|
||||||
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $score,
|
|
||||||
'confidence' => min(1.0, $rows->count() / 200),
|
|
||||||
'direction' => 'stable',
|
|
||||||
'detail' => $detail,
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Placeholder for regional momentum signal (requires lat/lng).
|
|
||||||
* Compares local station prices vs national average trend.
|
|
||||||
*
|
|
||||||
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
|
|
||||||
*/
|
|
||||||
private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array
|
|
||||||
{
|
|
||||||
// Regional momentum: compare trend of stations within 50km vs national trend
|
|
||||||
$rows = DB::table('station_prices')
|
|
||||||
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
|
||||||
->where('station_prices.fuel_type', $fuelType->value)
|
|
||||||
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
|
||||||
->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $lat, $lng, $lat])
|
|
||||||
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
|
||||||
->groupBy('day')
|
|
||||||
->orderBy('day')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if ($rows->count() < 3) {
|
|
||||||
return $this->disabledSignal('Insufficient regional data');
|
|
||||||
}
|
|
||||||
|
|
||||||
$regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
|
||||||
$direction = match (true) {
|
|
||||||
$regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
|
||||||
$regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
|
||||||
default => 'stable',
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
|
||||||
'confidence' => min(1.0, $regionalRegression['r_squared']),
|
|
||||||
'direction' => $direction,
|
|
||||||
'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')',
|
|
||||||
'data_points' => $rows->count(),
|
|
||||||
'enabled' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
|
||||||
private function disabledSignal(string $detail): array
|
private function disabledSignal(string $detail): array
|
||||||
{
|
{
|
||||||
@@ -400,46 +142,64 @@ class NationalFuelPredictionService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Weighted aggregate of enabled signals.
|
* Aggregate enabled signals into a final direction + confidence score.
|
||||||
* Returns [direction string, confidence score 0-100].
|
|
||||||
*
|
*
|
||||||
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals
|
* Direction: weighted vote across signals that have a non-stable direction.
|
||||||
|
* stable signals do NOT dilute the directional vote.
|
||||||
|
*
|
||||||
|
* Confidence: weighted average of enabled signals' own confidence values,
|
||||||
|
* multiplied by an agreement coefficient (0..1) measuring how the signals
|
||||||
|
* line up with the chosen direction.
|
||||||
|
*
|
||||||
|
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
|
||||||
* @return array{0: string, 1: float}
|
* @return array{0: string, 1: float}
|
||||||
*/
|
*/
|
||||||
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
|
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
|
||||||
{
|
{
|
||||||
$weights = $hasCoordinates
|
$weights = $hasCoordinates
|
||||||
? [
|
? [
|
||||||
'regionalMomentum' => 0.50,
|
'regionalMomentum' => 0.35,
|
||||||
'trend' => 0.20,
|
'oil' => 0.20,
|
||||||
|
'trend' => 0.15,
|
||||||
'dayOfWeek' => 0.15,
|
'dayOfWeek' => 0.15,
|
||||||
'brandBehaviour' => 0.10,
|
'brandBehaviour' => 0.10,
|
||||||
'stickiness' => 0.05,
|
'stickiness' => 0.05,
|
||||||
]
|
]
|
||||||
: [
|
: [
|
||||||
'trend' => 0.45,
|
'trend' => 0.30,
|
||||||
|
'oil' => 0.25,
|
||||||
'dayOfWeek' => 0.20,
|
'dayOfWeek' => 0.20,
|
||||||
'brandBehaviour' => 0.25,
|
'brandBehaviour' => 0.15,
|
||||||
'stickiness' => 0.10,
|
'stickiness' => 0.10,
|
||||||
];
|
];
|
||||||
|
|
||||||
$weightedSum = 0.0;
|
$directionalScoreSum = 0.0;
|
||||||
$totalWeight = 0.0;
|
$directionalWeightSum = 0.0;
|
||||||
|
$confidenceWeightedSum = 0.0;
|
||||||
|
$totalEnabledWeight = 0.0;
|
||||||
|
|
||||||
foreach ($weights as $key => $weight) {
|
foreach ($weights as $key => $weight) {
|
||||||
$signal = $signals[$key] ?? null;
|
$signal = $signals[$key] ?? null;
|
||||||
if ($signal && $signal['enabled']) {
|
if (! $signal || ! $signal['enabled']) {
|
||||||
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
|
continue;
|
||||||
$totalWeight += $weight;
|
}
|
||||||
|
|
||||||
|
$totalEnabledWeight += $weight;
|
||||||
|
$confidenceWeightedSum += $signal['confidence'] * $weight;
|
||||||
|
|
||||||
|
if ($signal['direction'] !== 'stable') {
|
||||||
|
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
|
||||||
|
$directionalWeightSum += $weight;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($totalWeight < 0.01) {
|
if ($totalEnabledWeight < 0.01) {
|
||||||
return ['stable', 0.0];
|
return ['stable', 0.0];
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalised = $weightedSum / $totalWeight;
|
$normalised = $directionalWeightSum > 0.01
|
||||||
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
|
? $directionalScoreSum / $directionalWeightSum
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
$direction = match (true) {
|
$direction = match (true) {
|
||||||
$normalised >= 0.1 => 'up',
|
$normalised >= 0.1 => 'up',
|
||||||
@@ -447,51 +207,185 @@ class NationalFuelPredictionService
|
|||||||
default => 'stable',
|
default => 'stable',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
|
||||||
|
$agreement = $this->computeAgreement($signals, $weights, $direction);
|
||||||
|
|
||||||
|
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
|
||||||
|
|
||||||
return [$direction, $confidenceScore];
|
return [$direction, $confidenceScore];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Least-squares linear regression.
|
* How well the enabled signals line up with the chosen direction.
|
||||||
* x is the array index (day number), y is the price value.
|
* - aligned signal: full credit (signal_confidence × weight)
|
||||||
|
* - one side stable, other directional: half credit
|
||||||
|
* - opposing signals: no credit
|
||||||
*
|
*
|
||||||
* @param float[] $values
|
* Range: 0 (full disagreement) → 1 (unanimous).
|
||||||
* @return array{slope: float, r_squared: float}
|
*
|
||||||
|
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
|
||||||
|
* @param array<string, float> $weights
|
||||||
*/
|
*/
|
||||||
private function linearRegression(array $values): array
|
private function computeAgreement(array $signals, array $weights, string $finalDirection): float
|
||||||
{
|
{
|
||||||
$n = count($values);
|
$finalDir = match ($finalDirection) {
|
||||||
if ($n < 2) {
|
'up' => 1,
|
||||||
return ['slope' => 0.0, 'r_squared' => 0.0];
|
'down' => -1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$credit = 0.0;
|
||||||
|
$maxCredit = 0.0;
|
||||||
|
|
||||||
|
foreach ($weights as $key => $weight) {
|
||||||
|
$signal = $signals[$key] ?? null;
|
||||||
|
if (! $signal || ! $signal['enabled']) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$maxCredit += $signal['confidence'] * $weight;
|
||||||
|
|
||||||
|
$signalDir = match ($signal['direction']) {
|
||||||
|
'up' => 1,
|
||||||
|
'down' => -1,
|
||||||
|
default => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($signalDir === $finalDir) {
|
||||||
|
$credit += $signal['confidence'] * $weight;
|
||||||
|
} elseif ($signalDir === 0 || $finalDir === 0) {
|
||||||
|
$credit += 0.5 * $signal['confidence'] * $weight;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$xMean = ($n - 1) / 2.0;
|
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
|
||||||
$yMean = array_sum($values) / $n;
|
|
||||||
|
|
||||||
$numerator = 0.0;
|
|
||||||
$denominator = 0.0;
|
|
||||||
|
|
||||||
foreach ($values as $i => $y) {
|
|
||||||
$x = $i - $xMean;
|
|
||||||
$numerator += $x * ($y - $yMean);
|
|
||||||
$denominator += $x * $x;
|
|
||||||
}
|
|
||||||
|
|
||||||
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
|
||||||
|
|
||||||
$ssRes = 0.0;
|
|
||||||
$ssTot = 0.0;
|
|
||||||
foreach ($values as $i => $y) {
|
|
||||||
$predicted = $yMean + $slope * ($i - $xMean);
|
|
||||||
$ssRes += ($y - $predicted) ** 2;
|
|
||||||
$ssTot += ($y - $yMean) ** 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
|
||||||
|
|
||||||
return ['slope' => $slope, 'r_squared' => $rSquared];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string
|
/**
|
||||||
|
* Yesterday / today / tomorrow snapshot + last-7-days series.
|
||||||
|
* Regional (50km) when coordinates are given, with national fallback when
|
||||||
|
* regional data is empty.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* yesterday_avg: ?float,
|
||||||
|
* today_avg: float,
|
||||||
|
* tomorrow_estimated_avg: ?float,
|
||||||
|
* yesterday_today_delta_pence: ?float,
|
||||||
|
* last_7_days_series: array<int, array{date: string, avg: float}>,
|
||||||
|
* last_7_days_change_pence: ?float,
|
||||||
|
* cheapest_day: ?array{date: string, avg: float},
|
||||||
|
* priciest_day: ?array{date: string, avg: float},
|
||||||
|
* is_regional: bool
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
|
||||||
|
{
|
||||||
|
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
|
||||||
|
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
|
||||||
|
|
||||||
|
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
|
||||||
|
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
|
||||||
|
|
||||||
|
$cheapestDay = null;
|
||||||
|
$priciestDay = null;
|
||||||
|
$weekChange = null;
|
||||||
|
|
||||||
|
if (count($series) >= 2) {
|
||||||
|
$byPrice = $series;
|
||||||
|
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
|
||||||
|
$cheapestDay = $byPrice[0];
|
||||||
|
$priciestDay = $byPrice[count($byPrice) - 1];
|
||||||
|
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'yesterday_avg' => $yesterdayAvg,
|
||||||
|
'today_avg' => $todayAvg,
|
||||||
|
'tomorrow_estimated_avg' => $tomorrowEstimated,
|
||||||
|
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
|
||||||
|
'last_7_days_series' => $series,
|
||||||
|
'last_7_days_change_pence' => $weekChange,
|
||||||
|
'cheapest_day' => $cheapestDay,
|
||||||
|
'priciest_day' => $priciestDay,
|
||||||
|
'is_regional' => $usedRegional,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
|
||||||
|
{
|
||||||
|
$dateString = $date->toDateString();
|
||||||
|
|
||||||
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
|
$regional = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $fuelType->value)
|
||||||
|
->whereDate('station_prices.price_effective_at', $dateString)
|
||||||
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
|
->avg('station_prices.price_pence');
|
||||||
|
|
||||||
|
if ($regional !== null) {
|
||||||
|
return round((float) $regional / 100, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$national = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $fuelType->value)
|
||||||
|
->whereDate('price_effective_at', $dateString)
|
||||||
|
->avg('price_pence');
|
||||||
|
|
||||||
|
return $national !== null ? round((float) $national / 100, 1) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
|
||||||
|
*/
|
||||||
|
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
|
||||||
|
{
|
||||||
|
$rows = collect();
|
||||||
|
$usedRegional = false;
|
||||||
|
|
||||||
|
if ($lat !== null && $lng !== null) {
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $fuelType->value)
|
||||||
|
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||||||
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
|
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$usedRegional = $rows->isNotEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rows->isEmpty()) {
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
|
||||||
|
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
$series = $rows->map(fn ($r): array => [
|
||||||
|
'date' => (string) $r->day,
|
||||||
|
'avg' => round((float) $r->avg_price / 100, 1),
|
||||||
|
])->values()->all();
|
||||||
|
|
||||||
|
return [$series, $usedRegional];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{enabled: bool, detail: string, direction: string} $trend
|
||||||
|
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
|
||||||
|
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
|
||||||
|
*/
|
||||||
|
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
|
||||||
{
|
{
|
||||||
$parts = [];
|
$parts = [];
|
||||||
|
|
||||||
@@ -503,8 +397,16 @@ class NationalFuelPredictionService
|
|||||||
$parts[] = $brandBehaviour['detail'];
|
$parts[] = $brandBehaviour['detail'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($dayOfWeek['enabled']) {
|
||||||
|
$parts[] = $dayOfWeek['detail'];
|
||||||
|
}
|
||||||
|
|
||||||
if (empty($parts)) {
|
if (empty($parts)) {
|
||||||
return 'No clear pattern — fill up at the cheapest station near you now.';
|
return match ($direction) {
|
||||||
|
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
|
||||||
|
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
|
||||||
|
default => 'No clear pattern — fill up at the cheapest station near you now.',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return implode(' ', $parts);
|
return implode(' ', $parts);
|
||||||
|
|||||||
@@ -1,190 +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\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class OilPriceService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Decay factor for EWMA. Higher = more weight on recent prices.
|
|
||||||
*/
|
|
||||||
private const float EWMA_ALPHA = 0.3;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum % change in EWMA to be considered rising/falling.
|
|
||||||
*/
|
|
||||||
private const float EWMA_THRESHOLD_PCT = 1.5;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* EWMA confidence is capped lower than LLM — it's a simpler model.
|
|
||||||
*/
|
|
||||||
private const int EWMA_MAX_CONFIDENCE = 65;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Minimum price rows needed before EWMA is meaningful.
|
|
||||||
*/
|
|
||||||
private const int EWMA_MIN_ROWS = 14;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly ApiLogger $apiLogger,
|
|
||||||
private readonly OilPredictionProvider $provider,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the last 30 days of Brent crude prices from FRED and store them.
|
|
||||||
*/
|
|
||||||
public function fetchBrentPrices(): void
|
|
||||||
{
|
|
||||||
$url = 'https://api.stlouisfed.org/fred/series/observations';
|
|
||||||
|
|
||||||
try {
|
|
||||||
$response = $this->apiLogger->send('fred', 'GET', $url, fn () => Http::timeout(10)
|
|
||||||
->get($url, [
|
|
||||||
'series_id' => 'DCOILBRENTEU',
|
|
||||||
'api_key' => config('services.fred.api_key'),
|
|
||||||
'sort_order' => 'desc',
|
|
||||||
'limit' => 30,
|
|
||||||
'file_type' => 'json',
|
|
||||||
]));
|
|
||||||
|
|
||||||
if (! $response->successful()) {
|
|
||||||
Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rows = collect($response->json('observations') ?? [])
|
|
||||||
->filter(fn (array $obs) => $obs['value'] !== '.') // FRED uses '.' for missing data
|
|
||||||
->map(fn (array $obs) => [
|
|
||||||
'date' => $obs['date'],
|
|
||||||
'price_usd' => (float) $obs['value'],
|
|
||||||
])
|
|
||||||
->all();
|
|
||||||
|
|
||||||
if (empty($rows)) {
|
|
||||||
Log::warning('OilPriceService: no valid FRED observations returned');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BrentPrice::upsert($rows, ['date'], ['price_usd']);
|
|
||||||
} catch (Throwable $e) {
|
|
||||||
Log::error('OilPriceService: fetchBrentPrices failed', ['error' => $e->getMessage()]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate predictions from all available sources and store each one.
|
|
||||||
* EWMA always runs. LLM provider runs and returns null if not configured.
|
|
||||||
* Returns the highest-confidence prediction (LLM preferred over EWMA).
|
|
||||||
*/
|
|
||||||
public function generatePrediction(): ?PricePrediction
|
|
||||||
{
|
|
||||||
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
|
|
||||||
|
|
||||||
if ($prices->count() < self::EWMA_MIN_ROWS) {
|
|
||||||
Log::warning('OilPriceService: not enough price data to generate prediction', [
|
|
||||||
'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());
|
|
||||||
}
|
|
||||||
|
|
||||||
return $llm ?? $ewma;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Option A — EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
|
|
||||||
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
|
|
||||||
*/
|
|
||||||
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(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compute Exponential Weighted Moving Average for a series of prices.
|
|
||||||
*
|
|
||||||
* @param float[] $prices Chronological order (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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score.
|
|
||||||
* 1.5% → ~30, 3% → ~50, 5%+ → 65.
|
|
||||||
*/
|
|
||||||
private function ewmaConfidence(float $changePct): int
|
|
||||||
{
|
|
||||||
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
|
|
||||||
|
|
||||||
return (int) round(max(30, $scaled));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
183
app/Services/PlanFeatures.php
Normal file
183
app/Services/PlanFeatures.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserNotificationPreference;
|
||||||
|
|
||||||
|
final class PlanFeatures
|
||||||
|
{
|
||||||
|
/** @var string[] */
|
||||||
|
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
|
||||||
|
|
||||||
|
private Plan $plan;
|
||||||
|
|
||||||
|
private function __construct(private readonly User $user)
|
||||||
|
{
|
||||||
|
$this->plan = Plan::resolveForUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function for(User $user): self
|
||||||
|
{
|
||||||
|
return new self($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channels allowed for a given trigger type, filtered by:
|
||||||
|
* tier allows → user has enabled → daily limit not hit.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
public function channelsFor(string $triggerType): array
|
||||||
|
{
|
||||||
|
$allowed = [];
|
||||||
|
|
||||||
|
foreach (self::CHANNELS as $channel) {
|
||||||
|
if (! $this->canUseChannel($channel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->userHasEnabledChannel($channel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->canSendNow($channel)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed[] = $channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the plan allows this channel at all. */
|
||||||
|
public function canUseChannel(string $channel): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->plan->{"{$channel}_enabled"};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a notification can be sent right now on this channel.
|
||||||
|
* Checks both the plan cap and today's live count in notification_log.
|
||||||
|
*/
|
||||||
|
public function canSendNow(string $channel): bool
|
||||||
|
{
|
||||||
|
if (! $this->canUseChannel($channel)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dailyLimit = $this->dailyLimit($channel);
|
||||||
|
|
||||||
|
// null = unlimited; 0 = blocked even though enabled
|
||||||
|
if ($dailyLimit === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dailyLimit === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentToday = NotificationLog::where('user_id', $this->user->id)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('sent', true)
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
|
||||||
|
return $sentToday < $dailyLimit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the user can track an additional fuel type. */
|
||||||
|
public function canTrackFuelType(string $fuelType): bool
|
||||||
|
{
|
||||||
|
$limit = $this->fuelTypeLimit();
|
||||||
|
|
||||||
|
if ($limit === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
|
||||||
|
->where('fuel_type', $fuelType)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyTracking) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->trackedFuelTypeCount() < $limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum fuel types allowed, or null for unlimited. */
|
||||||
|
public function fuelTypeLimit(): ?int
|
||||||
|
{
|
||||||
|
return $this->plan->max_fuel_types;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of distinct fuel types the user has preferences for. */
|
||||||
|
public function trackedFuelTypeCount(): int
|
||||||
|
{
|
||||||
|
return UserNotificationPreference::where('user_id', $this->user->id)
|
||||||
|
->distinct('fuel_type')
|
||||||
|
->count('fuel_type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generic boolean feature flag check. */
|
||||||
|
public function can(string $feature): bool
|
||||||
|
{
|
||||||
|
return (bool) ($this->plan->{$feature} ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of notifications missed today on a channel. */
|
||||||
|
public function missedToday(string $channel): int
|
||||||
|
{
|
||||||
|
return NotificationLog::where('user_id', $this->user->id)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereDate('created_at', today())
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Count of notifications missed this month on a channel. */
|
||||||
|
public function missedThisMonth(string $channel): int
|
||||||
|
{
|
||||||
|
return NotificationLog::where('user_id', $this->user->id)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('sent', false)
|
||||||
|
->whereMonth('created_at', now()->month)
|
||||||
|
->whereYear('created_at', now()->year)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The resolved plan tier name. */
|
||||||
|
public function tier(): string
|
||||||
|
{
|
||||||
|
return $this->plan->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
|
||||||
|
public function displayName(): string
|
||||||
|
{
|
||||||
|
return $this->plan->displayName();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the user has opted in to this channel for at least one fuel type. */
|
||||||
|
private function userHasEnabledChannel(string $channel): bool
|
||||||
|
{
|
||||||
|
return UserNotificationPreference::where('user_id', $this->user->id)
|
||||||
|
->where('channel', $channel)
|
||||||
|
->where('enabled', true)
|
||||||
|
->exists();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */
|
||||||
|
private function dailyLimit(string $channel): ?int
|
||||||
|
{
|
||||||
|
return match ($channel) {
|
||||||
|
'whatsapp' => $this->plan->whatsapp_daily_limit,
|
||||||
|
'sms' => $this->plan->sms_daily_limit,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Outcode;
|
||||||
|
use App\Models\Postcode;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
@@ -24,7 +26,16 @@ class PostcodeService
|
|||||||
public function resolve(string $query): ?LocationResult
|
public function resolve(string $query): ?LocationResult
|
||||||
{
|
{
|
||||||
$query = trim($query);
|
$query = trim($query);
|
||||||
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
|
|
||||||
|
if ($this->isFullPostcode($query)) {
|
||||||
|
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isOutcode($query)) {
|
||||||
|
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
|
||||||
|
|
||||||
$cached = Cache::get($cacheKey);
|
$cached = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -32,11 +43,7 @@ class PostcodeService
|
|||||||
return $cached;
|
return $cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = match (true) {
|
$result = $this->lookupPlace($query);
|
||||||
$this->isFullPostcode($query) => $this->lookupPostcode($query),
|
|
||||||
$this->isOutcode($query) => $this->lookupOutcode($query),
|
|
||||||
default => $this->lookupPlace($query),
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($result !== null) {
|
if ($result !== null) {
|
||||||
Cache::put($cacheKey, $result, self::CACHE_TTL);
|
Cache::put($cacheKey, $result, self::CACHE_TTL);
|
||||||
@@ -45,6 +52,11 @@ class PostcodeService
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function normalisePostcode(string $value): string
|
||||||
|
{
|
||||||
|
return strtoupper(preg_replace('/\s+/', '', $value));
|
||||||
|
}
|
||||||
|
|
||||||
private function isFullPostcode(string $query): bool
|
private function isFullPostcode(string $query): bool
|
||||||
{
|
{
|
||||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
|
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
|
||||||
@@ -55,9 +67,55 @@ class PostcodeService
|
|||||||
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
|
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function lookupLocalPostcode(string $postcode): ?LocationResult
|
||||||
|
{
|
||||||
|
$normalised = $this->normalisePostcode($postcode);
|
||||||
|
|
||||||
|
$row = Postcode::find($normalised);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocationResult(
|
||||||
|
query: $postcode,
|
||||||
|
displayName: $this->formatPostcode($normalised),
|
||||||
|
lat: $row->lat,
|
||||||
|
lng: $row->lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function lookupLocalOutcode(string $outcode): ?LocationResult
|
||||||
|
{
|
||||||
|
$normalised = strtoupper(trim($outcode));
|
||||||
|
|
||||||
|
$row = Outcode::find($normalised);
|
||||||
|
|
||||||
|
if ($row === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LocationResult(
|
||||||
|
query: $outcode,
|
||||||
|
displayName: $normalised,
|
||||||
|
lat: $row->lat,
|
||||||
|
lng: $row->lng,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatPostcode(string $normalised): string
|
||||||
|
{
|
||||||
|
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
|
||||||
|
if (strlen($normalised) < 5) {
|
||||||
|
return $normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($normalised, 0, -3).' '.substr($normalised, -3);
|
||||||
|
}
|
||||||
|
|
||||||
private function lookupPostcode(string $postcode): ?LocationResult
|
private function lookupPostcode(string $postcode): ?LocationResult
|
||||||
{
|
{
|
||||||
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode));
|
$normalised = $this->normalisePostcode($postcode);
|
||||||
$url = self::BASE_URL.'/postcodes/'.$normalised;
|
$url = self::BASE_URL.'/postcodes/'.$normalised;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -69,12 +127,34 @@ class PostcodeService
|
|||||||
|
|
||||||
$data = $response->json('result');
|
$data = $response->json('result');
|
||||||
|
|
||||||
return new LocationResult(
|
if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = new LocationResult(
|
||||||
query: $postcode,
|
query: $postcode,
|
||||||
displayName: $data['postcode'],
|
displayName: $data['postcode'],
|
||||||
lat: $data['latitude'],
|
lat: $data['latitude'],
|
||||||
lng: $data['longitude'],
|
lng: $data['longitude'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Postcode::updateOrCreate(
|
||||||
|
['postcode' => $normalised],
|
||||||
|
[
|
||||||
|
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
|
||||||
|
'lat' => $data['latitude'],
|
||||||
|
'lng' => $data['longitude'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
|
||||||
|
'postcode' => $normalised,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('PostcodeService: postcode lookup failed', [
|
Log::error('PostcodeService: postcode lookup failed', [
|
||||||
'postcode' => $postcode,
|
'postcode' => $postcode,
|
||||||
@@ -99,12 +179,33 @@ class PostcodeService
|
|||||||
|
|
||||||
$data = $response->json('result');
|
$data = $response->json('result');
|
||||||
|
|
||||||
return new LocationResult(
|
if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = new LocationResult(
|
||||||
query: $outcode,
|
query: $outcode,
|
||||||
displayName: $data['outcode'],
|
displayName: $data['outcode'],
|
||||||
lat: $data['latitude'],
|
lat: $data['latitude'],
|
||||||
lng: $data['longitude'],
|
lng: $data['longitude'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
Outcode::updateOrCreate(
|
||||||
|
['outcode' => $normalised],
|
||||||
|
[
|
||||||
|
'lat' => $data['latitude'],
|
||||||
|
'lng' => $data['longitude'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
|
||||||
|
'outcode' => $normalised,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('PostcodeService: outcode lookup failed', [
|
Log::error('PostcodeService: outcode lookup failed', [
|
||||||
'outcode' => $outcode,
|
'outcode' => $outcode,
|
||||||
|
|||||||
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
61
app/Services/Prediction/Signals/AbstractSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
abstract class AbstractSignal implements Signal
|
||||||
|
{
|
||||||
|
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
|
||||||
|
protected function disabledSignal(string $detail): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.0,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => 0,
|
||||||
|
'enabled' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Least-squares linear regression. x = array index, y = value.
|
||||||
|
*
|
||||||
|
* @param float[] $values
|
||||||
|
* @return array{slope: float, r_squared: float}
|
||||||
|
*/
|
||||||
|
protected function linearRegression(array $values): array
|
||||||
|
{
|
||||||
|
$n = count($values);
|
||||||
|
|
||||||
|
if ($n < 2) {
|
||||||
|
return ['slope' => 0.0, 'r_squared' => 0.0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$xMean = ($n - 1) / 2.0;
|
||||||
|
$yMean = array_sum($values) / $n;
|
||||||
|
|
||||||
|
$numerator = 0.0;
|
||||||
|
$denominator = 0.0;
|
||||||
|
|
||||||
|
foreach ($values as $i => $y) {
|
||||||
|
$x = $i - $xMean;
|
||||||
|
$numerator += $x * ($y - $yMean);
|
||||||
|
$denominator += $x * $x;
|
||||||
|
}
|
||||||
|
|
||||||
|
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
|
||||||
|
|
||||||
|
$ssRes = 0.0;
|
||||||
|
$ssTot = 0.0;
|
||||||
|
|
||||||
|
foreach ($values as $i => $y) {
|
||||||
|
$predicted = $yMean + $slope * ($i - $xMean);
|
||||||
|
$ssRes += ($y - $predicted) ** 2;
|
||||||
|
$ssTot += ($y - $yMean) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
|
||||||
|
|
||||||
|
return ['slope' => $slope, 'r_squared' => $rSquared];
|
||||||
|
}
|
||||||
|
}
|
||||||
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
61
app/Services/Prediction/Signals/BrandBehaviourSignal.php
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class BrandBehaviourSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||||
|
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
|
||||||
|
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
|
->groupBy('stations.is_supermarket', 'day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$supermarket = $rows->where('is_supermarket', 1)->values();
|
||||||
|
$major = $rows->where('is_supermarket', 0)->values();
|
||||||
|
|
||||||
|
if ($supermarket->count() < 2 || $major->count() < 2) {
|
||||||
|
return $this->disabledSignal('Insufficient brand data for comparison');
|
||||||
|
}
|
||||||
|
|
||||||
|
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||||
|
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
|
||||||
|
|
||||||
|
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
|
||||||
|
$supermarketChange = round($supermarketSlope * 7, 1);
|
||||||
|
$majorChange = round($majorSlope * 7, 1);
|
||||||
|
|
||||||
|
if ($divergence < 1.0) {
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.5,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => 'Supermarkets and majors moving in sync.',
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
|
||||||
|
$direction = $leaderChange > 0 ? 'up' : 'down';
|
||||||
|
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
|
||||||
|
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
|
||||||
|
$leaderAbs = abs($leaderChange);
|
||||||
|
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $direction === 'up' ? 1.0 : -1.0,
|
||||||
|
'confidence' => min(1.0, $divergence / 5.0),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
80
app/Services/Prediction/Signals/DayOfWeekSignal.php
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class DayOfWeekSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const int MIN_DAYS = 21;
|
||||||
|
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays(90))
|
||||||
|
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
|
||||||
|
->groupBy('dow', 'day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$uniqueDays = $rows->pluck('day')->unique()->count();
|
||||||
|
|
||||||
|
if ($uniqueDays < self::MIN_DAYS) {
|
||||||
|
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
|
||||||
|
}
|
||||||
|
|
||||||
|
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
|
||||||
|
$weekAvg = $dowAverages->avg();
|
||||||
|
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
|
||||||
|
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
|
||||||
|
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
|
||||||
|
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
|
||||||
|
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
|
||||||
|
|
||||||
|
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
|
||||||
|
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
|
||||||
|
|
||||||
|
$direction = match (true) {
|
||||||
|
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
|
||||||
|
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
|
||||||
|
|
||||||
|
$parts = [];
|
||||||
|
$parts[] = abs($todayDeltaPence) < 0.1
|
||||||
|
? "Today ({$todayName}) is typically in line with the weekly average."
|
||||||
|
: sprintf(
|
||||||
|
'Today (%s) is typically %sp %s the weekly average.',
|
||||||
|
$todayName,
|
||||||
|
number_format(abs($todayDeltaPence), 1),
|
||||||
|
$todayDeltaPence > 0 ? 'above' : 'below',
|
||||||
|
);
|
||||||
|
|
||||||
|
$parts[] = abs($tomorrowDeltaPence) < 0.1
|
||||||
|
? "Tomorrow ({$tomorrowName}) is typically the same."
|
||||||
|
: sprintf(
|
||||||
|
'Tomorrow (%s) is typically %sp %s.',
|
||||||
|
$tomorrowName,
|
||||||
|
number_format(abs($tomorrowDeltaPence), 1),
|
||||||
|
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($cheapestDow === $todayDow) {
|
||||||
|
$parts[] = 'Today is historically the cheapest day of the week.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $uniqueDays / 90),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => implode(' ', $parts),
|
||||||
|
'data_points' => $uniqueDays,
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
40
app/Services/Prediction/Signals/DbDialect.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL dialect helpers for the small set of MySQL/SQLite differences the
|
||||||
|
* signal classes care about. Centralises the isSqlite ternaries that were
|
||||||
|
* duplicated across DayOfWeekSignal and StickinessSignal.
|
||||||
|
*/
|
||||||
|
final class DbDialect
|
||||||
|
{
|
||||||
|
private static function isSqlite(): bool
|
||||||
|
{
|
||||||
|
return DB::connection()->getDriverName() === 'sqlite';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
|
||||||
|
* Targets a column on the queried table.
|
||||||
|
*/
|
||||||
|
public static function dayOfWeekExpr(string $column): string
|
||||||
|
{
|
||||||
|
return self::isSqlite()
|
||||||
|
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
|
||||||
|
: "DAYOFWEEK({$column})";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whole-day difference between MAX and MIN of a datetime column, suitable
|
||||||
|
* for use in an aggregate selectRaw.
|
||||||
|
*/
|
||||||
|
public static function maxMinDayDiffExpr(string $column): string
|
||||||
|
{
|
||||||
|
return self::isSqlite()
|
||||||
|
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
|
||||||
|
: "DATEDIFF(MAX({$column}), MIN({$column}))";
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
63
app/Services/Prediction/Signals/OilSignal.php
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class OilSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
|
||||||
|
* fallback) covering today or later. Sourced from price_predictions,
|
||||||
|
* which OilPriceService populates daily.
|
||||||
|
*/
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$prediction = null;
|
||||||
|
|
||||||
|
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
|
||||||
|
$prediction = DB::table('price_predictions')
|
||||||
|
->where('source', $source)
|
||||||
|
->where('predicted_for', '>=', now()->toDateString())
|
||||||
|
->orderByDesc('predicted_for')
|
||||||
|
->orderByDesc('generated_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($prediction !== null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($prediction === null) {
|
||||||
|
return $this->disabledSignal('No oil price prediction available');
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = match ($prediction->direction) {
|
||||||
|
'rising' => 'up',
|
||||||
|
'falling' => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
$score = match ($direction) {
|
||||||
|
'up' => 1.0,
|
||||||
|
'down' => -1.0,
|
||||||
|
default => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$confidence = round(((float) $prediction->confidence) / 100, 2);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => $confidence,
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => sprintf(
|
||||||
|
'Brent crude %s (%s, %d%% confidence)',
|
||||||
|
$prediction->direction,
|
||||||
|
$prediction->source,
|
||||||
|
(int) $prediction->confidence,
|
||||||
|
),
|
||||||
|
'data_points' => 1,
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
52
app/Services/Prediction/Signals/RegionalMomentumSignal.php
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use App\Services\HaversineQuery;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class RegionalMomentumSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
|
private const float REGIONAL_RADIUS_KM = 50.0;
|
||||||
|
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
if (! $context->hasCoordinates()) {
|
||||||
|
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
|
||||||
|
}
|
||||||
|
|
||||||
|
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
|
||||||
|
->where('station_prices.fuel_type', $context->fuelType->value)
|
||||||
|
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
|
||||||
|
->whereRaw($radiusSql, $radiusBindings)
|
||||||
|
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 3) {
|
||||||
|
return $this->disabledSignal('Insufficient regional data');
|
||||||
|
}
|
||||||
|
|
||||||
|
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||||
|
$direction = match (true) {
|
||||||
|
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||||
|
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
|
||||||
|
'confidence' => min(1.0, $regression['r_squared']),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/Signal.php
Normal file
24
app/Services/Prediction/Signals/Signal.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
interface Signal
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Evaluate the signal against the given context.
|
||||||
|
*
|
||||||
|
* Returns the canonical signal payload. Implementations may add extra
|
||||||
|
* keys beyond the base shape (e.g. trend adds slope + r_squared).
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* score: float,
|
||||||
|
* confidence: float,
|
||||||
|
* direction: string,
|
||||||
|
* detail: string,
|
||||||
|
* data_points: int,
|
||||||
|
* enabled: bool,
|
||||||
|
* ...
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function compute(SignalContext $context): array;
|
||||||
|
}
|
||||||
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
24
app/Services/Prediction/Signals/SignalContext.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inputs required to evaluate a prediction signal. Individual signals may
|
||||||
|
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
|
||||||
|
* RegionalMomentumSignal requires lat/lng to be non-null.
|
||||||
|
*/
|
||||||
|
final readonly class SignalContext
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public FuelType $fuelType,
|
||||||
|
public ?float $lat = null,
|
||||||
|
public ?float $lng = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function hasCoordinates(): bool
|
||||||
|
{
|
||||||
|
return $this->lat !== null && $this->lng !== null;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
50
app/Services/Prediction/Signals/StickinessSignal.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class StickinessSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
|
||||||
|
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays(30))
|
||||||
|
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
|
||||||
|
->groupBy('station_id')
|
||||||
|
->having('changes', '>', 1)
|
||||||
|
->having('span_days', '>', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 10) {
|
||||||
|
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
|
||||||
|
$avgHoldDays = round((float) $avgHoldDays, 1);
|
||||||
|
|
||||||
|
$score = match (true) {
|
||||||
|
$avgHoldDays < 2 => -0.1,
|
||||||
|
$avgHoldDays > 5 => 0.1,
|
||||||
|
default => 0.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
$detail = match (true) {
|
||||||
|
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
|
||||||
|
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
|
||||||
|
default => "Normal hold period (avg: {$avgHoldDays} days).",
|
||||||
|
};
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $rows->count() / 200),
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
86
app/Services/Prediction/Signals/TrendSignal.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Prediction\Signals;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
final class TrendSignal extends AbstractSignal
|
||||||
|
{
|
||||||
|
private const float R_SQUARED_THRESHOLD = 0.5;
|
||||||
|
|
||||||
|
private const float SLOPE_THRESHOLD_PENCE = 0.3;
|
||||||
|
|
||||||
|
private const float SLOPE_SATURATION_PENCE = 0.5;
|
||||||
|
|
||||||
|
private const int PREDICTION_HORIZON_DAYS = 7;
|
||||||
|
|
||||||
|
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
|
||||||
|
public function compute(SignalContext $context): array
|
||||||
|
{
|
||||||
|
foreach ([5, 14] as $lookbackDays) {
|
||||||
|
$rows = DB::table('station_prices')
|
||||||
|
->where('fuel_type', $context->fuelType->value)
|
||||||
|
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
|
||||||
|
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
|
||||||
|
->groupBy('day')
|
||||||
|
->orderBy('day')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($rows->count() < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
|
||||||
|
|
||||||
|
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
|
||||||
|
$slope = $regression['slope'];
|
||||||
|
$direction = match (true) {
|
||||||
|
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
|
||||||
|
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
|
||||||
|
default => 'stable',
|
||||||
|
};
|
||||||
|
$absSlope = abs($slope);
|
||||||
|
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
|
||||||
|
$projected = round($slope * $lookbackDays, 1);
|
||||||
|
$detail = $direction === 'stable'
|
||||||
|
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
|
||||||
|
: sprintf(
|
||||||
|
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
|
||||||
|
$slope > 0 ? 'Rising' : 'Falling',
|
||||||
|
abs(round($slope, 2)),
|
||||||
|
$lookbackDays,
|
||||||
|
round($regression['r_squared'], 2),
|
||||||
|
$projected > 0 ? '+' : '',
|
||||||
|
$projected,
|
||||||
|
self::PREDICTION_HORIZON_DAYS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($lookbackDays === 5) {
|
||||||
|
$detail .= ' [Adaptive lookback active]';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => $score,
|
||||||
|
'confidence' => min(1.0, $regression['r_squared']),
|
||||||
|
'direction' => $direction,
|
||||||
|
'detail' => $detail,
|
||||||
|
'data_points' => $rows->count(),
|
||||||
|
'enabled' => true,
|
||||||
|
'slope' => round($slope, 3),
|
||||||
|
'r_squared' => round($regression['r_squared'], 3),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'score' => 0.0,
|
||||||
|
'confidence' => 0.0,
|
||||||
|
'direction' => 'stable',
|
||||||
|
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
|
||||||
|
'data_points' => 0,
|
||||||
|
'enabled' => false,
|
||||||
|
'slope' => 0.0,
|
||||||
|
'r_squared' => 0.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Services/StationSearch/SearchCriteria.php
Normal file
16
app/Services/StationSearch/SearchCriteria.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
|
||||||
|
final readonly class SearchCriteria
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public float $lat,
|
||||||
|
public float $lng,
|
||||||
|
public FuelType $fuelType,
|
||||||
|
public float $radiusKm,
|
||||||
|
public string $sort,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
21
app/Services/StationSearch/SearchResult.php
Normal file
21
app/Services/StationSearch/SearchResult.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final readonly class SearchResult
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $stations Sorted station rows with _updated_at/_reliability/_classification cached
|
||||||
|
* @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary
|
||||||
|
* @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts
|
||||||
|
* @param array<string, mixed> $prediction
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Collection $stations,
|
||||||
|
public array $pricesSummary,
|
||||||
|
public array $reliabilityCounts,
|
||||||
|
public array $prediction,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
151
app/Services/StationSearch/StationSearchService.php
Normal file
151
app/Services/StationSearch/StationSearchService.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\StationSearch;
|
||||||
|
|
||||||
|
use App\Enums\PriceClassification;
|
||||||
|
use App\Enums\PriceReliability;
|
||||||
|
use App\Models\Search;
|
||||||
|
use App\Models\Station;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Services\HaversineQuery;
|
||||||
|
use App\Services\NationalFuelPredictionService;
|
||||||
|
use App\Services\PlanFeatures;
|
||||||
|
use Illuminate\Database\Query\JoinClause;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
final class StationSearchService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly NationalFuelPredictionService $predictionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
|
||||||
|
{
|
||||||
|
$stations = $this->fetchAndSortStations($criteria);
|
||||||
|
$prices = $stations->pluck('price_pence');
|
||||||
|
|
||||||
|
$this->logSearch($criteria, $stations->count(), $prices, $ipHash);
|
||||||
|
|
||||||
|
return new SearchResult(
|
||||||
|
stations: $stations,
|
||||||
|
pricesSummary: [
|
||||||
|
'lowest' => $prices->min(),
|
||||||
|
'highest' => $prices->max(),
|
||||||
|
'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
],
|
||||||
|
reliabilityCounts: $this->countReliability($stations),
|
||||||
|
prediction: $this->buildPrediction($user, $criteria),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, mixed> */
|
||||||
|
private function fetchAndSortStations(SearchCriteria $criteria): Collection
|
||||||
|
{
|
||||||
|
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng);
|
||||||
|
|
||||||
|
$all = Station::query()
|
||||||
|
->selectRaw(
|
||||||
|
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
|
||||||
|
$distanceBindings,
|
||||||
|
)
|
||||||
|
->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void {
|
||||||
|
$join->on('stations.node_id', '=', 'spc.station_id')
|
||||||
|
->where('spc.fuel_type', '=', $criteria->fuelType->value);
|
||||||
|
})
|
||||||
|
->where('stations.temporary_closure', false)
|
||||||
|
->where('stations.permanent_closure', false)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Compute reliability + classification once per row so the sort, the
|
||||||
|
// count groupBy, and the StationResource render all read cached
|
||||||
|
// values instead of re-invoking PriceReliability::fromUpdatedAt.
|
||||||
|
$all->each(function ($s): void {
|
||||||
|
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
|
||||||
|
$s->_updated_at = $updatedAt;
|
||||||
|
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
|
||||||
|
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm);
|
||||||
|
|
||||||
|
return $this->applySort($filtered, $criteria->sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $filtered
|
||||||
|
* @return Collection<int, mixed>
|
||||||
|
*/
|
||||||
|
private function applySort(Collection $filtered, string $sort): Collection
|
||||||
|
{
|
||||||
|
if ($sort === 'reliable') {
|
||||||
|
return $filtered
|
||||||
|
->sort(function ($a, $b) {
|
||||||
|
return $a->_reliability->weight() <=> $b->_reliability->weight()
|
||||||
|
?: ((int) $a->price_pence <=> (int) $b->price_pence)
|
||||||
|
?: ((float) $a->distance_km <=> (float) $b->distance_km);
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $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,
|
||||||
|
default => fn ($s) => (float) $s->distance_km,
|
||||||
|
})->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, mixed> $stations
|
||||||
|
* @return array{reliable: int, stale: int, outdated: int}
|
||||||
|
*/
|
||||||
|
private function countReliability(Collection $stations): array
|
||||||
|
{
|
||||||
|
$counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0),
|
||||||
|
'stale' => (int) $counts->get(PriceReliability::Stale->value, 0),
|
||||||
|
'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, mixed> $prices */
|
||||||
|
private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void
|
||||||
|
{
|
||||||
|
Search::create([
|
||||||
|
'lat_bucket' => round($criteria->lat, 2),
|
||||||
|
'lng_bucket' => round($criteria->lng, 2),
|
||||||
|
'fuel_type' => $criteria->fuelType->value,
|
||||||
|
'results_count' => $resultsCount,
|
||||||
|
'lowest_pence' => $prices->min(),
|
||||||
|
'highest_pence' => $prices->max(),
|
||||||
|
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
|
||||||
|
'searched_at' => now(),
|
||||||
|
'ip_hash' => $ipHash ?? hash('sha256', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Free/guest users get a stripped teaser; users with the ai_predictions
|
||||||
|
* feature get the full multi-signal payload.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function buildPrediction(?User $user, SearchCriteria $criteria): array
|
||||||
|
{
|
||||||
|
$result = $this->predictionService->predict($criteria->lat, $criteria->lng);
|
||||||
|
|
||||||
|
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
|
||||||
|
|
||||||
|
if (! $canSeeFull) {
|
||||||
|
return [
|
||||||
|
'fuel_type' => $result['fuel_type'],
|
||||||
|
'predicted_direction' => $result['predicted_direction'],
|
||||||
|
'tier_locked' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\RequiresFeature;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->statefulApi();
|
$middleware->statefulApi();
|
||||||
|
$middleware->alias([
|
||||||
|
'feature' => RequiresFeature::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
|
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.4",
|
"php": "^8.4",
|
||||||
"filament/filament": "^5.0",
|
"filament/filament": "^5.0",
|
||||||
|
"laravel/cashier": "^16.5",
|
||||||
"laravel/fortify": "^1.34",
|
"laravel/fortify": "^1.34",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
|
|||||||
328
composer.lock
generated
328
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "789a2e6b542a1e2f263dc8e9c973423b",
|
"content-hash": "9035b4713dec553cc69f487efa60cade",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "bacon/bacon-qr-code",
|
"name": "bacon/bacon-qr-code",
|
||||||
@@ -2124,6 +2124,95 @@
|
|||||||
},
|
},
|
||||||
"time": "2026-03-29T12:05:03+00:00"
|
"time": "2026-03-29T12:05:03+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "laravel/cashier",
|
||||||
|
"version": "v16.5.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/laravel/cashier-stripe.git",
|
||||||
|
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/49a581bccb5e56a45e1c8ee94587ce3420203a7a",
|
||||||
|
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/log": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/notifications": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/pagination": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"illuminate/view": "^10.0|^11.0|^12.0|^13.0",
|
||||||
|
"moneyphp/money": "^4.0",
|
||||||
|
"nesbot/carbon": "^2.0|^3.0",
|
||||||
|
"php": "^8.1",
|
||||||
|
"stripe/stripe-php": "^17.3.0",
|
||||||
|
"symfony/console": "^6.0|^7.0|^8.0",
|
||||||
|
"symfony/http-kernel": "^6.0|^7.0|^8.0",
|
||||||
|
"symfony/polyfill-intl-icu": "^1.22.1",
|
||||||
|
"symfony/polyfill-php84": "^1.32"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"dompdf/dompdf": "^2.0|^3.0",
|
||||||
|
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
|
||||||
|
"phpstan/phpstan": "^1.10",
|
||||||
|
"spatie/laravel-ray": "^1.40"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
|
||||||
|
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.",
|
||||||
|
"spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer."
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Laravel\\Cashier\\CashierServiceProvider"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "16.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Laravel\\Cashier\\": "src/",
|
||||||
|
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Taylor Otwell",
|
||||||
|
"email": "taylor@laravel.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dries Vints",
|
||||||
|
"email": "dries@laravel.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
|
||||||
|
"keywords": [
|
||||||
|
"billing",
|
||||||
|
"laravel",
|
||||||
|
"stripe"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/laravel/cashier/issues",
|
||||||
|
"source": "https://github.com/laravel/cashier"
|
||||||
|
},
|
||||||
|
"time": "2026-04-01T15:57:36+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/fortify",
|
"name": "laravel/fortify",
|
||||||
"version": "v1.36.2",
|
"version": "v1.36.2",
|
||||||
@@ -3537,6 +3626,96 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-04-02T20:48:35+00:00"
|
"time": "2026-04-02T20:48:35+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "moneyphp/money",
|
||||||
|
"version": "v4.8.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/moneyphp/money.git",
|
||||||
|
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
|
||||||
|
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-bcmath": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"cache/taggable-cache": "^1.1.0",
|
||||||
|
"doctrine/coding-standard": "^12.0",
|
||||||
|
"doctrine/instantiator": "^1.5.0 || ^2.0",
|
||||||
|
"ext-gmp": "*",
|
||||||
|
"ext-intl": "*",
|
||||||
|
"florianv/exchanger": "^2.8.1",
|
||||||
|
"florianv/swap": "^4.3.0",
|
||||||
|
"moneyphp/crypto-currencies": "^1.1.0",
|
||||||
|
"moneyphp/iso-currencies": "^3.4",
|
||||||
|
"php-http/message": "^1.16.0",
|
||||||
|
"php-http/mock-client": "^1.6.0",
|
||||||
|
"phpbench/phpbench": "^1.2.5",
|
||||||
|
"phpstan/extension-installer": "^1.4",
|
||||||
|
"phpstan/phpstan": "^2.1.9",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0",
|
||||||
|
"phpunit/phpunit": "^10.5.9",
|
||||||
|
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
|
||||||
|
"ticketswap/phpstan-error-formatter": "^1.1"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-gmp": "Calculate without integer limits",
|
||||||
|
"ext-intl": "Format Money objects with intl",
|
||||||
|
"florianv/exchanger": "Exchange rates library for PHP",
|
||||||
|
"florianv/swap": "Exchange rates library for PHP",
|
||||||
|
"psr/cache-implementation": "Used for Currency caching"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Money\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Mathias Verraes",
|
||||||
|
"email": "mathias@verraes.net",
|
||||||
|
"homepage": "http://verraes.net"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Frederik Bosch",
|
||||||
|
"email": "f.bosch@genkgo.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP implementation of Fowler's Money pattern",
|
||||||
|
"homepage": "http://moneyphp.org",
|
||||||
|
"keywords": [
|
||||||
|
"Value Object",
|
||||||
|
"money",
|
||||||
|
"vo"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/moneyphp/money/issues",
|
||||||
|
"source": "https://github.com/moneyphp/money/tree/v4.8.0"
|
||||||
|
},
|
||||||
|
"time": "2025-10-23T07:55:09+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "monolog/monolog",
|
"name": "monolog/monolog",
|
||||||
"version": "3.10.0",
|
"version": "3.10.0",
|
||||||
@@ -5508,6 +5687,65 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-02-01T09:30:04+00:00"
|
"time": "2026-02-01T09:30:04+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "stripe/stripe-php",
|
||||||
|
"version": "v17.6.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/stripe/stripe-php.git",
|
||||||
|
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
|
||||||
|
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-curl": "*",
|
||||||
|
"ext-json": "*",
|
||||||
|
"ext-mbstring": "*",
|
||||||
|
"php": ">=5.6.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"friendsofphp/php-cs-fixer": "3.72.0",
|
||||||
|
"phpstan/phpstan": "^1.2",
|
||||||
|
"phpunit/phpunit": "^5.7 || ^9.0"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.0-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Stripe\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Stripe and contributors",
|
||||||
|
"homepage": "https://github.com/stripe/stripe-php/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Stripe PHP Library",
|
||||||
|
"homepage": "https://stripe.com/",
|
||||||
|
"keywords": [
|
||||||
|
"api",
|
||||||
|
"payment processing",
|
||||||
|
"stripe"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/stripe/stripe-php/issues",
|
||||||
|
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
|
||||||
|
},
|
||||||
|
"time": "2025-08-27T19:32:42+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/clock",
|
"name": "symfony/clock",
|
||||||
"version": "v8.0.8",
|
"version": "v8.0.8",
|
||||||
@@ -6708,6 +6946,94 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-06-27T09:58:17+00:00"
|
"time": "2025-06-27T09:58:17+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfony/polyfill-intl-icu",
|
||||||
|
"version": "v1.35.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/symfony/polyfill-intl-icu.git",
|
||||||
|
"reference": "3510b63d07376b04e57e27e82607d468bb134f78"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78",
|
||||||
|
"reference": "3510b63d07376b04e57e27e82607d468bb134f78",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=7.2"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"ext-intl": "For best performance and support of other locales than \"en\""
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"thanks": {
|
||||||
|
"url": "https://github.com/symfony/polyfill",
|
||||||
|
"name": "symfony/polyfill"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"files": [
|
||||||
|
"bootstrap.php"
|
||||||
|
],
|
||||||
|
"psr-4": {
|
||||||
|
"Symfony\\Polyfill\\Intl\\Icu\\": ""
|
||||||
|
},
|
||||||
|
"classmap": [
|
||||||
|
"Resources/stubs"
|
||||||
|
],
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"/Tests/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nicolas Grekas",
|
||||||
|
"email": "p@tchwork.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Symfony Community",
|
||||||
|
"homepage": "https://symfony.com/contributors"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Symfony polyfill for intl's ICU-related data and classes",
|
||||||
|
"homepage": "https://symfony.com",
|
||||||
|
"keywords": [
|
||||||
|
"compatibility",
|
||||||
|
"icu",
|
||||||
|
"intl",
|
||||||
|
"polyfill",
|
||||||
|
"portable",
|
||||||
|
"shim"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.35.0"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://symfony.com/sponsor",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/fabpot",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/nicolas-grekas",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
|
||||||
|
"type": "tidelift"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2026-04-10T16:50:15+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/polyfill-intl-idn",
|
"name": "symfony/polyfill-intl-idn",
|
||||||
"version": "v1.33.0",
|
"version": "v1.33.0",
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ return [
|
|||||||
'api_key' => env('FRED_API_KEY'),
|
'api_key' => env('FRED_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'eia' => [
|
||||||
|
'api_key' => env('EIA_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
'anthropic' => [
|
'anthropic' => [
|
||||||
'api_key' => env('ANTHROPIC_API_KEY'),
|
'api_key' => env('ANTHROPIC_API_KEY'),
|
||||||
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
|
||||||
@@ -68,4 +72,33 @@ return [
|
|||||||
'api_key' => env('FUELALERT_API_KEY'),
|
'api_key' => env('FUELALERT_API_KEY'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'onesignal' => [
|
||||||
|
'app_id' => env('ONESIGNAL_APP_ID'),
|
||||||
|
'api_key' => env('ONESIGNAL_API_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'vonage' => [
|
||||||
|
'key' => env('VONAGE_KEY'),
|
||||||
|
'secret' => env('VONAGE_SECRET'),
|
||||||
|
'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'),
|
||||||
|
'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'stripe' => [
|
||||||
|
'prices' => [
|
||||||
|
'basic' => [
|
||||||
|
'monthly' => env('STRIPE_PRICE_BASIC_MONTHLY'),
|
||||||
|
'annual' => env('STRIPE_PRICE_BASIC_ANNUAL'),
|
||||||
|
],
|
||||||
|
'plus' => [
|
||||||
|
'monthly' => env('STRIPE_PRICE_PLUS_MONTHLY'),
|
||||||
|
'annual' => env('STRIPE_PRICE_PLUS_ANNUAL'),
|
||||||
|
],
|
||||||
|
'pro' => [
|
||||||
|
'monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
|
||||||
|
'annual' => env('STRIPE_PRICE_PRO_ANNUAL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
36
database/factories/NotificationLogFactory.php
Normal file
36
database/factories/NotificationLogFactory.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\NotificationLog;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<NotificationLog>
|
||||||
|
*/
|
||||||
|
class NotificationLogFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
|
||||||
|
'trigger_type' => fake()->randomElement(['price_threshold', 'score_change', 'scheduled_morning', 'scheduled_evening']),
|
||||||
|
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
|
||||||
|
'price' => fake()->optional()->randomFloat(3, 100, 180),
|
||||||
|
'sent' => true,
|
||||||
|
'missed_reason' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function missed(string $reason = 'daily_limit'): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'sent' => false,
|
||||||
|
'missed_reason' => $reason,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
111
database/factories/PlanFactory.php
Normal file
111
database/factories/PlanFactory.php
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\PlanTier;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<Plan>
|
||||||
|
*/
|
||||||
|
class PlanFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => PlanTier::Free->value,
|
||||||
|
'stripe_price_id_monthly' => null,
|
||||||
|
'stripe_price_id_annual' => null,
|
||||||
|
'max_fuel_types' => 1,
|
||||||
|
'email_enabled' => true,
|
||||||
|
'email_frequency' => 'weekly_digest',
|
||||||
|
'push_enabled' => false,
|
||||||
|
'push_frequency' => 'none',
|
||||||
|
'whatsapp_enabled' => false,
|
||||||
|
'whatsapp_daily_limit' => 0,
|
||||||
|
'whatsapp_scheduled_updates' => 0,
|
||||||
|
'sms_enabled' => false,
|
||||||
|
'sms_daily_limit' => 0,
|
||||||
|
'ai_predictions' => false,
|
||||||
|
'price_threshold' => false,
|
||||||
|
'score_alerts' => false,
|
||||||
|
'active' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function free(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'name' => PlanTier::Free->value,
|
||||||
|
'stripe_price_id_monthly' => null,
|
||||||
|
'stripe_price_id_annual' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function basic(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'name' => PlanTier::Basic->value,
|
||||||
|
'stripe_price_id_monthly' => 'price_basic_monthly_test',
|
||||||
|
'stripe_price_id_annual' => 'price_basic_annual_test',
|
||||||
|
'max_fuel_types' => 1,
|
||||||
|
'email_enabled' => true,
|
||||||
|
'email_frequency' => 'daily',
|
||||||
|
'push_enabled' => true,
|
||||||
|
'push_frequency' => 'daily',
|
||||||
|
'whatsapp_enabled' => true,
|
||||||
|
'whatsapp_daily_limit' => 5,
|
||||||
|
'whatsapp_scheduled_updates' => 2,
|
||||||
|
'sms_enabled' => false,
|
||||||
|
'sms_daily_limit' => 0,
|
||||||
|
'ai_predictions' => false,
|
||||||
|
'price_threshold' => true,
|
||||||
|
'score_alerts' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function plus(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'name' => PlanTier::Plus->value,
|
||||||
|
'stripe_price_id_monthly' => 'price_plus_monthly_test',
|
||||||
|
'stripe_price_id_annual' => 'price_plus_annual_test',
|
||||||
|
'max_fuel_types' => 1,
|
||||||
|
'email_enabled' => true,
|
||||||
|
'email_frequency' => 'triggered',
|
||||||
|
'push_enabled' => true,
|
||||||
|
'push_frequency' => 'triggered',
|
||||||
|
'whatsapp_enabled' => true,
|
||||||
|
'whatsapp_daily_limit' => 5,
|
||||||
|
'whatsapp_scheduled_updates' => 2,
|
||||||
|
'sms_enabled' => true,
|
||||||
|
'sms_daily_limit' => 1,
|
||||||
|
'ai_predictions' => true,
|
||||||
|
'price_threshold' => true,
|
||||||
|
'score_alerts' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pro(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn () => [
|
||||||
|
'name' => PlanTier::Pro->value,
|
||||||
|
'stripe_price_id_monthly' => 'price_pro_monthly_test',
|
||||||
|
'stripe_price_id_annual' => 'price_pro_annual_test',
|
||||||
|
'max_fuel_types' => null,
|
||||||
|
'email_enabled' => true,
|
||||||
|
'email_frequency' => 'triggered',
|
||||||
|
'push_enabled' => true,
|
||||||
|
'push_frequency' => 'triggered',
|
||||||
|
'whatsapp_enabled' => true,
|
||||||
|
'whatsapp_daily_limit' => 5,
|
||||||
|
'whatsapp_scheduled_updates' => 2,
|
||||||
|
'sms_enabled' => true,
|
||||||
|
'sms_daily_limit' => 3,
|
||||||
|
'ai_predictions' => true,
|
||||||
|
'price_threshold' => true,
|
||||||
|
'score_alerts' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
database/factories/UserNotificationPreferenceFactory.php
Normal file
24
database/factories/UserNotificationPreferenceFactory.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Enums\FuelType;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\UserNotificationPreference;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends Factory<UserNotificationPreference>
|
||||||
|
*/
|
||||||
|
class UserNotificationPreferenceFactory extends Factory
|
||||||
|
{
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
|
||||||
|
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
|
||||||
|
'enabled' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('notification_log', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('channel')->comment('email | push | whatsapp | sms');
|
||||||
|
$table->string('trigger_type')->comment('price_threshold | score_change | scheduled_morning | scheduled_evening');
|
||||||
|
$table->string('fuel_type');
|
||||||
|
$table->decimal('price', 8, 3)->nullable();
|
||||||
|
$table->boolean('sent');
|
||||||
|
$table->string('missed_reason')->nullable()->comment('daily_limit | tier_restricted | user_disabled');
|
||||||
|
$table->timestamp('created_at')->useCurrent();
|
||||||
|
|
||||||
|
$table->index(['user_id', 'channel', 'created_at']);
|
||||||
|
$table->index(['user_id', 'sent', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('notification_log');
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user