Compare commits

..

27 Commits

Author SHA1 Message Date
Ovidiu U
25cf022964 feat: add prediction rebuild design spec — Layer 1 ridge model, LLM news overlay, volatility regime detector
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Documents complete replacement of six-signal aggregator with calibrated
ridge forecaster trained on 435 weeks of BEIS pump prices. Five-layer
architecture: weekly baseline (Layer 1), local snapshot (Layer 2),
rule-gated verdict merger (Layer 3), daily LLM news
2026-05-01 13:23:10 +01:00
Ovidiu U
e821a934a5 feat: add weekly_pump_prices migration for BEIS fuel price data
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Created migration for storing UK weekly pump prices from BEIS publications.
Table uses Monday date as primary key and stores petrol/diesel pump prices,
duty, and VAT rates as integer pence or percentage values.
2026-05-01 13:22:50 +01:00
Ovidiu U
73de53994f fix: prevent sensitive field leaks in /me, add retry logic to Brent price sources
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
- Made `/api/auth/me` public and return explicit allowlist (name, email,
  two_factor_confirmed_at, tier, subscription fields) instead of spreading
  `$user->toArray()` which leaked is_admin, stripe_id, pm_type, pm_last_four,
  postcode. Returns `null` when unauthenticated rather than 401.
- Moved `/auth/logout` to remain behind auth:sanctum gate.
- Added 3×200ms retry with exponential backoff to EiaBrentPriceSource and
  FredBrentPriceSource on ConnectionException or 5xx responses. Timeout
  raised from 10s to 30s.
- Both sources now throw typed BrentPriceFetchException on exhausted retries
  instead of silently returning null + logging. Updated tests to assert
  exception message includes HTTP status or "connection failed".
2026-05-01 13:22:36 +01:00
Ovidiu U
df70e514e9 refactor: add hard-stop documentation and deny-list for destructive DB commands
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Documented explicit prohibition of `migrate:fresh`, `migrate:reset`,
`db:wipe`, and raw DROP/TRUNCATE operations in CLAUDE.md. Prose rule
clarifies that user phrases like "trust me" or "do the refactor" are
not authorisation for schema rebuilds — architectural decision is
separate from operational step.

Added matching deny patterns to `.claude/settings.json` to block
direct inv
2026-04-30 09:01:20 +01:00
Ovidiu U
28061541d4 refactor: remove auto-scroll on stations loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Disabled the watch hook that scrolled the viewport when station results
were loading. The 40px nudge disrupted user focus and was not needed
for usability.
2026-04-30 09:01:11 +01:00
Ovidiu U
895d55439b refactor: extract StationSearchService
Audit item #8. StationController::index was ~100 lines doing the
haversine join, in-PHP filter/sort/group, search-row write, and
prediction call — well past the "thin orchestrator" line in
.claude/rules/architecture.md.

- App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/
  radiusKm/sort)
- App\Services\StationSearch\SearchResult — DTO (stations, prices
  summary, reliability counts, prediction payload)
- App\Services\StationSearch\StationSearchService::search(criteria,
  ?user, ?ipHash) — owns the haversine query, the per-row reliability
  memoisation, sort, count, search-row logging, and the tier-gated
  prediction.

The controller now resolves coordinates, builds a SearchCriteria, calls
the service, and shapes the JSON response. Down from 154 → 71 lines.
Public API contract unchanged — all 15 StationController tests pass
without modification.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 08:20:23 +01:00
Ovidiu U
aff6dd1e0f refactor: SendScheduledWhatsAppJob — drop redundant filtering
Audit item #12. The fan-out job ran an upfront Plan query plus a
per-user tier-name comparison before checking canSendNow('whatsapp').
Both are already covered by canSendNow → canUseChannel + daily-limit
count, so the parent was duplicating filtering work that the child
DispatchUserNotificationJob would do anyway via channelsFor().

Now the parent does only the cheap pre-check (canSendNow) before
dispatching the per-user child job. Iteration uses chunkById(500) to
make the memory bound explicit. Each user remains its own queueable
unit — independent retry, no shared failure mode across the cohort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:30 +01:00
Ovidiu U
06f5f2035f refactor: extract iterateBatches helper in FuelPriceService
Audit item #9. pollPrices() and refreshStations() shared the same
do/while batch loop with token + try/catch + Log::error + clean-exit
detection but different bodies. Extracted to a private iterateBatches()
that takes the endpoint, optional since timestamp, and a process
callable. Returns [total_processed, completedCleanly] so pollPrices
can still gate its incremental-poll cache update on a clean exhaustion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:21 +01:00
Ovidiu U
69eb524e07 fix: ImportPostcodes streams into staging, swaps on success
Audit item #13. The command truncated postcodes + outcodes immediately
and then chunk-inserted from the CSV. A mid-stream failure (network
drive disconnect, malformed row) left the production tables empty
until a successful re-run.

Now streams into a postcodes_staging table created via Schema::create
(works on both MySQL and SQLite) and only swaps into the live tables
once the full CSV has been consumed. A try/finally ensures the file
handle and staging table are cleaned up on any path. Live data is now
preserved on partial failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:15 +01:00
Ovidiu U
b4ef1177b2 refactor: PollFuelPricesJob calls service directly
Audit item #11. The job was a one-line Artisan::call wrapper around
fuel:poll --full. Routing through Artisan adds output buffering and
swallows typed exceptions before they reach the queue's failed-job
handler. Now injects FuelPriceService and replicates the --full path
(refreshStations + pollPrices + PricesUpdatedEvent) directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:21:05 +01:00
Ovidiu U
8e29980dfe perf: memoize PriceReliability + PriceClassification per row
Audit item #10. PriceReliability::fromUpdatedAt was being invoked
~10× per station per /api/stations response — twice in the sort
comparator (once for $a, once for $b), once in the count groupBy, and
once per resource render. PriceClassification::fromUpdatedAt was
called twice inside the resource (value + label).

The controller now computes the parsed datetime + reliability +
classification once per row and stashes them on the row. Sort,
groupBy, and StationResource read the cached values; the resource
keeps a fresh-compute fallback for callers that bypass the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:20:59 +01:00
Ovidiu U
4ce5066596 refactor: persist EWMA only on LLM failure, dedup EWMA helper
Audit items #7 and #5.

#7 — BrentPricePredictor::generatePrediction previously wrote both an
EWMA row and an LLM row to price_predictions on every run. The
downstream OilSignal already prefers llm_with_context > llm > ewma, so
the EWMA row was dead weight 95% of the time. Now we try LLM first; if
it returns null (no API key, parse failure, etc.) we compute and persist
EWMA as a real fallback. This also avoids redundant work on the success
path.

Updated the "stores both" test to "stores only LLM" — asserts no EWMA
row is written when the provider succeeds.

#5 — BrentPricePredictor and AnthropicPredictionProvider both had
byte-identical computeEwma() methods with identical EWMA_ALPHA = 0.3
constants. Extracted to App\Services\Ewma::compute() and dropped both
private methods + their alpha constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:04:41 +01:00
Ovidiu U
c46b017b51 chore: audit nits — PlanFeatures, test boot, EIA log, static method
Audit items #15, #16, #20, #22.

#15 — AuthController::me and UserResource form/table now read tier via
PlanFeatures::for($user)->tier() instead of Plan::resolveForUser($user)
->name. Tiers.md: PlanFeatures is the single entitlement gate.

#16 — Moved SQLite GREATEST/LEAST PHP-backed function registration from
AppServiceProvider::boot to tests/TestCase::setUp. Production app boot no
longer checks the DB driver name.

#20 — FetchOilPrices: added Log::warning on EIA fallback and Log::error
on both-providers-failed so primary-source reliability can be trended
beyond the cron output buffer.

#22 — FuelPriceService::flattenEnabledFlags is now an instance method,
matching the rest of the class. No external callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:09 +01:00
Ovidiu U
7f64c42a23 refactor: extract DbDialect helper, inline ProfileValidationRules trait
Audit items #17 and #21.

#17 — DayOfWeekSignal and StickinessSignal each had their own
isSqlite ternary picking between SQLite (strftime/julianday) and
MySQL (DAYOFWEEK/DATEDIFF) date expressions. Centralised in
App\Services\Prediction\Signals\DbDialect.

#21 — ProfileValidationRules was a trait with one consumer
(CreateNewUser); inlined the rules into the action and deleted the
trait. Also dropped PasswordValidationRules::currentPasswordRules()
which was unused. PasswordValidationRules trait stays (two consumers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 20:00:01 +01:00
Ovidiu U
4d9df1ee19 chore: add OneSignal and Vonage env keys to .env.example
Follow-up to the FuelPriceAlert notification wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:21 +01:00
Ovidiu U
5369b4a5a0 feat: build FuelPriceAlert notification with multi-channel adapters
The DispatchUserNotificationJob has been logging sent=true for every
allowed channel without actually sending anything — a TODO marker covered
by the existing test suite, which only asserted log rows. The downstream
"missed today" widget read those rows and reported falsely. This commit
makes the telemetry truthful by wiring the real send.

- App\Notifications\FuelPriceAlert — Notification class with via() that
  returns the per-tier-filtered channel list passed in by the dispatcher.
  Implements toMail / toOneSignal / toVonageWhatsApp / toVonageSms.
  ShouldQueue on the 'notifications' queue.
- App\Notifications\Channels\OneSignalChannel — raw HTTP to OneSignal
  REST API, gated on services.onesignal.{app_id,api_key} + user
  push_token. Logs every call to api_logs via ApiLogger.
- App\Notifications\Channels\VonageWhatsAppChannel — raw HTTP to Vonage
  Messages API, gated on whatsapp_verified_at + whatsapp_number.
- App\Notifications\Channels\VonageSmsChannel — raw HTTP to Vonage SMS
  API, gated on whatsapp_number.
- DispatchUserNotificationJob now calls $user->notify(new
  FuelPriceAlert(...)) before logging.
- New tests: assert the notification IS dispatched with the right
  channels, and that nothing is dispatched when no channels are allowed.

Channels gracefully no-op when their credentials are unset (logging at
info level), so existing tests without a Notification::fake() still
pass — the channels just early-return on missing config.

No new composer dependencies — Vonage SDK avoided in favour of raw HTTP
through the existing ApiLogger pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:48:10 +01:00
Ovidiu U
27c82ef103 refactor: extract 6 prediction signals into Signal classes
The 803-line NationalFuelPredictionService had six private compute*Signal
methods, a private linearRegression helper, and a private disabledSignal
shape factory all crammed together. Each signal is now an independently
testable class.

- App\Services\Prediction\Signals\Signal — interface
- App\Services\Prediction\Signals\SignalContext — input value object
  (FuelType + optional lat/lng + hasCoordinates() helper)
- App\Services\Prediction\Signals\AbstractSignal — shared
  disabledSignal() and linearRegression() helpers
- TrendSignal, DayOfWeekSignal, BrandBehaviourSignal, StickinessSignal,
  RegionalMomentumSignal, OilSignal — one class each, extending
  AbstractSignal

NationalFuelPredictionService receives the 6 signal classes via constructor
injection and orchestrates them. The lat/lng null-guard for regional
momentum now lives inside RegionalMomentumSignal::compute() so the
coordinator no longer branches on coordinate presence.

Aggregation, weekly summary, and reasoning helpers stay in the service
for now — they are coupled to the public predict() output shape and are
candidates for a follow-up extraction once a stable API is locked in.

Service: 803 → 414 lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:43:28 +01:00
Ovidiu U
e39618f5df refactor: extract AbstractLlmPredictionProvider for shared boilerplate
Anthropic, Gemini, and OpenAi providers each repeated: API-key gate,
chronological price-list building, response validation
(direction/confidence/reasoning), TrendDirection::tryFrom, confidence
cap at 85, and the top-level try/catch + Log::error.

Now in AbstractLlmPredictionProvider:
- LLM_MAX_CONFIDENCE constant
- buildPriceList(Collection) helper
- buildPrediction(input, ?source) — handles direction validation,
  confidence cap, model construction
- defaultPrompt(priceList) — shared by Gemini and OpenAi
- Default predict() flow (apiKey + callProvider + buildPrediction +
  try/catch). Gemini and OpenAi only implement apiKey() and
  callProvider(). Anthropic overrides predict() because of its
  multi-phase web-search + forced-tool flow but reuses the helpers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:35:57 +01:00
Ovidiu U
00d0f7c8ec refactor: extract HaversineQuery helper, fix LL bind quirk
The 5 haversine SQL fragments duplicated across StationController and
NationalFuelPredictionService disagreed on float-clamping (LEAST only,
GREATEST/LEAST, vs. CASE WHEN). Centralised in App\Services\HaversineQuery
with the safe GREATEST(-1.0, LEAST(1.0, …)) form everywhere.

withinKm() embeds the radius as a numeric literal (sprintf %F) because
PDO + SQLite binds float parameters as strings by default, which breaks
numeric comparison against the haversine expression — a NULL filter would
silently match all rows. Coordinates remain bound positionally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 19:33:07 +01:00
Ovidiu U
48af2083f3 feat: add fuel:archive command and monthly scheduler entry
Completes Tasks 12 + 13 from docs/superpowers/plans/2026-04-03-fuel-api-ingestion.md.

- ArchiveOldPricesCommand moves station_prices rows older than 12 months
  into station_prices_archive in chunks of 1000, wrapping each chunk's
  insert + delete in a DB::transaction so a partial failure can't
  duplicate rows.
- StationPriceArchive: add $table = 'station_prices_archive'; without it
  Eloquent infers 'station_price_archives' and the insert would fail.
- routes/console.php: register fuel:archive monthly on the 1st at 04:00
  UTC, alongside the other fuel/oil scheduler entries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:33:05 +01:00
Ovidiu U
783297694c fix: model audit cleanups (primaryKey, fuel_type cast, cadence cache)
- StationPriceCurrent: $primaryKey was null; set to 'station_id' + keyType
  string so Eloquent has a sensible default for save() / find() paths.
- UserNotificationPreference: add FuelType enum cast on fuel_type so it
  hydrates as an enum like every other price model.
- Plan::resolveCadenceForUser: cache for 1h under the same plans tag as
  resolveForUser; HandleStripeWebhook busts both keys on subscription
  events.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:32:55 +01:00
Ovidiu U
775e076bb7 Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00
Ovidiu U
8695d5ec95 refactor: flatten plans.features JSON to typed columns
The features JSON column required defensive fallback stubs in three
places (Plan::resolveForUser, PlanFeatures::__construct, PlanSeeder)
and silently swallowed misspelled keys. Typed columns give Eloquent
type-safe reads, simplify the Filament form (no more dotted JSON
paths), and let resolveForUser fail loud when the free row is
missing.

PlanFeatures public API is unchanged so consumers (jobs, middleware)
need no rewrites — one missed JSON read in SendScheduledWhatsAppJob
was caught and converted to a typed where() query.

tests/Pest.php seeds PlanSeeder in beforeEach so any feature test
that resolves a plan finds the free row, mirroring production where
plans always exist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 18:13:26 +01:00
Ovidiu U
088fd11058 Remove prediction API endpoint and integrate into stations search
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Consolidate prediction functionality by merging /api/prediction endpoint into /api/stations response. Move prediction logic from PredictionController into StationController, returning prediction data alongside station results. Replace usePrediction composable with unified useStations that returns {stations, meta, prediction}. Remove PredictionRequest, related tests, and unused Vue components (FuelFinderTest, MapTest, RecommendationTest, StationListTest). Add PredictionFull component and UpsellBanner. Extend NationalFuelPredictionService to include weekly_summary (7-day series, yesterday/today averages, cheapest/priciest days) and oil signal from price_predictions table. Update Home.vue to consume prediction from stations response. Add Plan::resolveCadenceForUser helper and configure Cashier to use custom Subscription model.
2026-04-29 13:28:33 +01:00
Ovidiu U
ee6de23709 feat: gate full prediction by ai_predictions feature flag
Add a prediction box above filter results on the homepage.
Server returns the full payload only when PlanFeatures::can(
'ai_predictions') — currently plus and pro. Other tiers and
guests get a trimmed {fuel_type, predicted_direction,
tier_locked: true} response so the gate is enforced server-side.

Frontend renders a compact one-liner with the national trend
direction for trimmed responses, full card for unlocked.

Hide the Pro plan card from the pricing section (pro plan
disabled in DB pending real Stripe price ids), and only show
the bottom signup CTA when the visitor is a guest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:29 +01:00
Ovidiu U
2ff3aeba4d fix: admin tier assignment when stripe price env vars are empty
env() returns an empty string (not null) when a STRIPE_PRICE_*
var is set but blank, so the ?? fallback never fired and the
synthetic subscription was created with stripe_price = '' —
which then resolved back to free in Plan::resolveForUser.

Switch to ?: so empty strings also fall back to the synthetic
price_admin_{tier}_{cadence} id, and backfill the matching Plan
row's stripe_price_id_{cadence} when empty so resolution succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:21 +01:00
Ovidiu U
b8adb81c79 chore: gitignore ONSPD source CSV
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:07 +01:00
98 changed files with 4458 additions and 1749 deletions

211
.claude/rules/prediction.md Normal file
View 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 (0100) | 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 |
| 4069 | `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"
---

30
.claude/settings.json Normal file
View 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 *)"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel
APP_NAME="Fuel Finder"
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_URL=http://fuel-price.test
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=fuel-price
DB_USERNAME=fuel-price
DB_PASSWORD=password
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SESSION_DOMAIN=.fuel-price.test
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -64,19 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
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=
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
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=
STRIPE_PRICE_BASIC_ANNUAL=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL=
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

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ yarn-error.log
/.zed
/.tmp/
/.worktrees/
/ONSPD_Online_Latest_Centroids_*.csv

View File

@@ -3,6 +3,20 @@
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
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
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
@@ -31,6 +45,7 @@ npm run dev # Vite asset watcher
@.claude/rules/database.md
@.claude/rules/notifications.md
@.claude/rules/scoring.md
@.claude/rules/prediction.md
@.claude/rules/payments.md
@.claude/rules/tiers.md
@.claude/rules/livewire.md

View File

@@ -3,14 +3,14 @@
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Laravel\Fortify\Contracts\CreatesNewUsers;
class CreateNewUser implements CreatesNewUsers
{
use PasswordValidationRules, ProfileValidationRules;
use PasswordValidationRules;
/**
* Validate and create a newly registered user.
@@ -20,7 +20,8 @@ class CreateNewUser implements CreatesNewUsers
public function create(array $input): User
{
Validator::make($input, [
...$this->profileRules(),
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)],
'password' => $this->passwordRules(),
])->validate();

View File

@@ -16,14 +16,4 @@ trait PasswordValidationRules
{
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'];
}
}

View File

@@ -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),
];
}
}

View 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;
}
}

View File

@@ -7,6 +7,7 @@ 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)')]
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
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...');
}
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
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;

View File

@@ -5,7 +5,10 @@ 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')]
@@ -79,56 +82,78 @@ final class ImportPostcodes extends Command
$hasDoterm = isset($columns['doterm']);
DB::table('postcodes')->truncate();
DB::table('outcodes')->truncate();
// 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;
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
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 = [];
}
}
$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')->insert($buffer);
if ($buffer !== []) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
$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');
}
if ($buffer !== []) {
DB::table('postcodes')->insert($buffer);
$imported += count($buffer);
}
fclose($handle);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
$this->info("Imported {$imported} postcodes.");
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');

View File

@@ -16,7 +16,7 @@ class PlanForm
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('features.fuel_types.max')
TextInput::make('max_fuel_types')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
@@ -28,9 +28,9 @@ class PlanForm
Section::make('Email')
->columns(2)
->schema([
Toggle::make('features.email.enabled')
Toggle::make('email_enabled')
->label('Enabled'),
Select::make('features.email.frequency')
Select::make('email_frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
@@ -42,9 +42,9 @@ class PlanForm
Section::make('Push')
->columns(2)
->schema([
Toggle::make('features.push.enabled')
Toggle::make('push_enabled')
->label('Enabled'),
Select::make('features.push.frequency')
Select::make('push_frequency')
->label('Frequency')
->options([
'none' => 'None (disabled)',
@@ -56,15 +56,15 @@ class PlanForm
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('features.whatsapp.enabled')
Toggle::make('whatsapp_enabled')
->label('Enabled'),
TextInput::make('features.whatsapp.daily_limit')
TextInput::make('whatsapp_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('features.whatsapp.scheduled_updates')
TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
@@ -75,9 +75,9 @@ class PlanForm
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('features.sms.enabled')
Toggle::make('sms_enabled')
->label('Enabled'),
TextInput::make('features.sms.daily_limit')
TextInput::make('sms_daily_limit')
->label('Daily limit')
->numeric()
->integer()
@@ -87,11 +87,11 @@ class PlanForm
Section::make('Features')
->schema([
Toggle::make('features.ai_predictions')
Toggle::make('ai_predictions')
->label('AI predictions'),
Toggle::make('features.price_threshold')
Toggle::make('price_threshold')
->label('Price threshold alerts'),
Toggle::make('features.score_alerts')
Toggle::make('score_alerts')
->label('Score change alerts'),
]),
]);

View File

@@ -17,16 +17,16 @@ class PlansTable
->label('Tier')
->badge()
->sortable(),
TextColumn::make('features.email.frequency')
TextColumn::make('email_frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('features.sms.daily_limit')
TextColumn::make('sms_daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('features.whatsapp.daily_limit')
TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('features.fuel_types.max')
TextColumn::make('max_fuel_types')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')

View File

@@ -9,6 +9,7 @@ use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
@@ -75,7 +76,7 @@ class UserResource extends Resource
->live()
->dehydrated(false)
->afterStateHydrated(fn (Select $component, ?User $record) => $component
->state($record ? Plan::resolveForUser($record)->name : PlanTier::Free->value)),
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
Select::make('cadence')
->label('Billing Cadence')
->options([
@@ -131,7 +132,7 @@ class UserResource extends Resource
TextColumn::make('postcode')->placeholder('—'),
TextColumn::make('tier')
->label('Tier')
->state(fn (User $record): string => Plan::resolveForUser($record)->name)
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
->badge()
->colors([
'gray' => 'free',
@@ -182,7 +183,14 @@ class UserResource extends Resource
return;
}
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?? "price_admin_{$tier}_{$cadence}";
$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',

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@@ -64,9 +65,23 @@ class AuthController extends Controller
{
$user = $request->user();
return response()->json(array_merge(
$user->toArray(),
['tier' => Plan::resolveForUser($user)->name],
));
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(),
]);
}
}

View File

@@ -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);
}
}

View File

@@ -2,23 +2,60 @@
namespace App\Http\Controllers\Api;
use App\Enums\PriceReliability;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
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\Support\Carbon;
use Illuminate\Validation\ValidationException;
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
{
[$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')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString());
@@ -27,94 +64,9 @@ class StationController extends Controller
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
}
$lat = $location->lat;
$lng = $location->lng;
} else {
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
return [$location->lat, $location->lng];
}
$fuelType = $request->fuelType();
$radius = $request->radius();
$sort = $request->sort();
$all = Station::query()
->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
->sort(function ($a, $b) {
$weightA = PriceReliability::fromUpdatedAt(
$a->price_effective_at ? Carbon::parse($a->price_effective_at) : null
)->weight();
$weightB = PriceReliability::fromUpdatedAt(
$b->price_effective_at ? Carbon::parse($b->price_effective_at) : null
)->weight();
return $weightA <=> $weightB
?: ((int) $a->price_pence <=> (int) $b->price_pence)
?: ((float) $a->distance_km <=> (float) $b->distance_km);
})
->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,
default => fn ($s) => (float) $s->distance_km,
})->values();
$prices = $stations->pluck('price_pence');
$reliabilityCounts = $stations
->groupBy(fn ($s) => PriceReliability::fromUpdatedAt(
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
)->value)
->map->count();
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,
'reliability_counts' => [
'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0),
'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0),
'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0),
],
],
]);
return [(float) $request->input('lat'), (float) $request->input('lng')];
}
}

View File

@@ -5,6 +5,7 @@ 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
@@ -12,7 +13,7 @@ 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
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);

View File

@@ -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'],
];
}
}

View File

@@ -12,8 +12,13 @@ class StationResource extends JsonResource
{
public function toArray(Request $request): array
{
$updatedAt = $this->price_effective_at ? Carbon::parse($this->price_effective_at) : null;
$reliability = PriceReliability::fromUpdatedAt($updatedAt);
// 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 [
'station_id' => $this->node_id,
@@ -32,11 +37,9 @@ class StationResource extends JsonResource
'open_today' => $this->openTodayPayload(),
'price_pence' => (int) $this->price_pence,
'price' => round((int) $this->price_pence / 100, 2),
'price_updated_at' => $this->price_effective_at
? Carbon::parse($this->price_effective_at)->toISOString()
: null,
'price_classification' => PriceClassification::fromUpdatedAt($updatedAt)->value,
'price_classification_label' => PriceClassification::fromUpdatedAt($updatedAt)->label(),
'price_updated_at' => $updatedAt?->toISOString(),
'price_classification' => $classification->value,
'price_classification_label' => $classification->label(),
'reliability' => $reliability->value,
'reliability_label' => $reliability->label(),
];

View File

@@ -5,15 +5,15 @@ 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, sends
* notifications, and logs every outcome (sent, daily_limit, tier_restricted).
*
* Actual sending is stubbed until FuelPriceAlert notification class exists.
* 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
{
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
// Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType);
// Step 4: send and log sent notifications
// Step 4: dispatch the multi-channel notification — Laravel fans out
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
if ($allowed !== []) {
$this->user->notify(new FuelPriceAlert(
$this->triggerType,
$this->fuelType,
$this->price,
$allowed,
));
}
// Step 5: log a sent entry per allowed channel. The notify() call
// above queues per-channel sends; per-channel HTTP outcomes are
// captured in api_logs by the channel adapters themselves.
foreach ($allowed as $channel) {
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
$this->log($channel, sent: true);
}

View File

@@ -2,16 +2,26 @@
namespace App\Jobs;
use App\Events\PricesUpdatedEvent;
use App\Services\FuelPriceService;
use Illuminate\Contracts\Queue\ShouldQueue;
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
{
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);
}
}

View File

@@ -2,16 +2,18 @@
namespace App\Jobs;
use App\Models\Plan;
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).
* Finds all eligible users and dispatches DispatchUserNotificationJob per user.
* Dispatches one DispatchUserNotificationJob per eligible user so each
* user's send is its own queueable unit (independent retry, no shared
* failure mode across the cohort).
*
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
{
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Plans that allow scheduled WhatsApp updates
$eligiblePlanNames = Plan::where('active', true)
->get()
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0)
->pluck('name')
->all();
if (empty($eligiblePlanNames)) {
return;
}
// Users who have whatsapp preference enabled
// 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)
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void {
$features = PlanFeatures::for($user);
->chunkById(500, function (Collection $users) use ($triggerType): void {
foreach ($users as $user) {
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
continue;
}
// Skip if their tier isn't eligible or daily limit is hit
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
return;
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
}
if (! $features->canSendNow('whatsapp')) {
return;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
});
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Listeners;
use App\Jobs\SendPaymentFailedReminderJob;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Support\Carbon;
@@ -28,15 +29,27 @@ final class HandleStripeWebhook
match ($type) {
'customer.subscription.created',
'customer.subscription.updated' => $this->bustPlanCache($user),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user),
'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,
};
}
private function handleSubscriptionDeleted(User $user): void
/**
* @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)
@@ -45,9 +58,52 @@ final class HandleStripeWebhook
$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();
@@ -73,6 +129,8 @@ final class HandleStripeWebhook
private function bustPlanCache(User $user): void
{
Cache::tags(['plans'])->forget("plan_for_user_{$user->id}");
$tag = Cache::tags(['plans']);
$tag->forget("plan_for_user_{$user->id}");
$tag->forget("plan_cadence_for_user_{$user->id}");
}
}

View File

@@ -17,7 +17,19 @@ class Plan extends Model
'name',
'stripe_price_id_monthly',
'stripe_price_id_annual',
'features',
'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',
];
@@ -56,28 +68,50 @@ class Plan extends Model
}
);
if ($planId !== null) {
$plan = static::find($planId);
return static::findOrFail($planId);
}
if ($plan !== null) {
return $plan;
/**
* 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;
}
}
// Fallback for tests / partially-seeded environments: return a free-tier stub.
return new self([
'name' => PlanTier::Free->value,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
);
}
protected static function booted(): void
@@ -92,7 +126,17 @@ class Plan extends Model
protected function casts(): array
{
return [
'features' => 'array',
'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => 'boolean',
];
}

View File

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
@@ -39,11 +38,17 @@ class PricePrediction extends Model
*/
public function scopeBestFirst(Builder $query): Builder
{
$priority = implode(', ', array_map(
fn (string $v) => "'$v'",
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
));
$priority = [
PredictionSource::LlmWithContext->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');
}
}

View File

@@ -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'])]
class StationPriceArchive extends Model
{
protected $table = 'station_prices_archive';
public $timestamps = false;
protected function casts(): array
{
return [
'fuel_type' => FuelType::class,
'fuel_type' => FuelType::class,
'price_effective_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
'price_reported_at' => 'datetime',
'recorded_at' => 'datetime',
];
}

View File

@@ -19,7 +19,9 @@ class StationPriceCurrent extends Model
public $timestamps = false;
protected $primaryKey = null;
protected $primaryKey = 'station_id';
protected $keyType = 'string';
public $incrementing = false;

View 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',
];
}

View File

@@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\FuelType;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -44,6 +45,7 @@ class UserNotificationPreference extends Model
{
return [
'enabled' => 'boolean',
'fuel_type' => FuelType::class,
];
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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()]);
}
}
}

View 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();
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Providers;
use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider;
@@ -14,6 +15,7 @@ use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
class AppServiceProvider extends ServiceProvider
@@ -41,6 +43,8 @@ class AppServiceProvider extends ServiceProvider
{
$this->configureDefaults();
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
}
@@ -55,13 +59,6 @@ class AppServiceProvider extends ServiceProvider
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::min(12)
->mixedCase()

View File

@@ -12,8 +12,6 @@ use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_ALPHA = 0.3;
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
@@ -33,8 +31,10 @@ final class BrentPricePredictor
}
/**
* Generate EWMA + LLM predictions, store them, and flag the latest
* brent_prices row as having a prediction generated.
* 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
{
@@ -48,25 +48,23 @@ final class BrentPricePredictor
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());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
return $llm;
}
$result = $llm ?? $ewma;
$ewma = $this->generateEwmaPrediction($prices);
if ($result !== null) {
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $result;
return $ewma;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
@@ -77,8 +75,8 @@ final class BrentPricePredictor
return null;
}
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
$ewma3 = Ewma::compute(array_slice($chronological, -3));
$ewma7 = Ewma::compute(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
@@ -112,20 +110,6 @@ final class BrentPricePredictor
]);
}
/**
* @param float[] $prices Chronological (oldest first).
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class EiaBrentPriceSource
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10)
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'api_key' => config('services.eia.api_key'),
'frequency' => 'daily',
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
'sort[0][direction]' => 'desc',
'length' => 30,
]));
if (! $response->successful()) {
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$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();
if ($rows === []) {
Log::warning('EiaBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('EiaBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} 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());
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
final class FredBrentPriceSource
@@ -14,12 +15,16 @@ final class FredBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10)
$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'),
@@ -27,32 +32,26 @@ final class FredBrentPriceSource
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('FredBrentPriceSource: request failed', ['status' => $response->status()]);
return null;
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
if ($rows === []) {
Log::warning('FredBrentPriceSource: no valid observations returned');
return null;
}
return $rows;
} catch (Throwable $e) {
Log::error('FredBrentPriceSource: fetch failed', ['error' => $e->getMessage()]);
return null;
} 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
View 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);
}
}

View File

@@ -67,57 +67,15 @@ class FuelPriceService
*/
public function pollPrices(): int
{
$token = $this->getAccessToken();
$inserted = 0;
$batch = 1;
$pollStartedAt = now();
$since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
$completedCleanly = false;
$sinceCarbon = $since instanceof CarbonInterface ? $since : null;
do {
try {
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices';
$params = ['batch-number' => $batch];
if ($since instanceof CarbonInterface) {
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
}
$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()) {
$completedCleanly = true;
break;
}
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)) {
$completedCleanly = true;
break;
}
$inserted += $this->processPriceBatch($stations);
$batch++;
} while (true);
[$inserted, $completedCleanly] = $this->iterateBatches(
'/pfs/fuel-prices',
$sinceCarbon,
fn (array $stations): int => $this->processPriceBatch($stations),
);
if ($completedCleanly) {
Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
@@ -131,25 +89,53 @@ class FuelPriceService
* Called on full daily refresh before pollPrices().
*/
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();
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
$total = 0;
$batch = 1;
$completedCleanly = false;
do {
try {
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
$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);
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
->withToken($token)
->get($baseUrl, $params));
if ($response->notFound()) {
break; // No more batches
$completedCleanly = true;
break;
}
if (! $response->successful()) {
Log::error('FuelPriceService: station batch returned error', [
Log::error('FuelPriceService: batch returned error', [
'endpoint' => $endpoint,
'batch' => $batch,
'status' => $response->status(),
]);
@@ -158,7 +144,8 @@ class FuelPriceService
$stations = $response->json() ?? [];
} catch (Throwable $e) {
Log::error('FuelPriceService: station batch fetch failed', [
Log::error('FuelPriceService: batch fetch failed', [
'endpoint' => $endpoint,
'batch' => $batch,
'error' => $e->getMessage(),
]);
@@ -166,12 +153,15 @@ class FuelPriceService
}
if (empty($stations)) {
$completedCleanly = true;
break;
}
$this->upsertStations($stations);
$total += $process($stations);
$batch++;
} while (true);
return [$total, $completedCleanly];
}
/** @param array<int, array<string, mixed>> $apiStations */
@@ -209,9 +199,9 @@ class FuelPriceService
'postcode' => $data['location']['postcode'],
'lat' => $data['location']['latitude'],
'lng' => $data['location']['longitude'],
'amenities' => self::flattenEnabledFlags($data['amenities'] ?? []),
'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
'opening_times' => $data['opening_times'] ?? null,
'fuel_types' => self::flattenEnabledFlags($data['fuel_types'] ?? []),
'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
'last_seen_at' => $now,
]);
@@ -242,7 +232,7 @@ class FuelPriceService
* @param array<string, bool>|array<int, string> $flags
* @return array<int, string>
*/
private static function flattenEnabledFlags(array $flags): array
private function flattenEnabledFlags(array $flags): array
{
if ($flags === []) {
return [];

View 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]];
}
}

View 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 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -3,31 +3,23 @@
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 App\Services\Ewma;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
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.
* 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
{
if (! config('services.anthropic.api_key')) {
if ($this->apiKey() === null) {
return null;
}
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
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.
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop).
* Phase 2: Force submit_prediction with the full conversation context.
* Phase 1: let the model search for recent oil/geopolitical news.
* Phase 2: force submit_prediction with the full conversation context.
*/
private function predictWithWebContext(Collection $prices): ?PricePrediction
{
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$url = 'https://api.anthropic.com/v1/messages';
try {
// Phase 1: web search loop
for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers())
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
]));
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;
}
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
}
// Phase 2: forced submit with full context
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
]));
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;
}
$input = $this->extractToolInput($submitResponse->json('content') ?? []);
if ($input === null) {
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response');
return $input === null
? 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 $this->buildPrediction($input, PredictionSource::LlmWithContext);
$input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]);
Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
PROMPT;
}
private function buildPriceList(Collection $prices): string
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
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 35 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> */
private function headers(): array
{
return [
'x-api-key' => config('services.anthropic.api_key'),
'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01',
];
}
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
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 35 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;
}
}

View File

@@ -2,110 +2,59 @@
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\Log;
use Throwable;
class GeminiPredictionProvider implements OilPredictionProvider
class GeminiPredictionProvider extends AbstractLlmPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
protected function apiKey(): ?string
{
if (! config('services.gemini.api_key')) {
return null;
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
return config('services.gemini.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
try {
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => config('services.gemini.api_key')])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->prompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
->withQueryParameters(['key' => $apiKey])
->post($url, [
'contents' => [[
'parts' => [['text' => $this->defaultPrompt($priceList)]],
]],
'generationConfig' => [
'responseMimeType' => 'application/json',
'responseSchema' => [
'type' => 'OBJECT',
'properties' => [
'direction' => [
'type' => 'STRING',
'enum' => ['rising', 'falling', 'flat'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
],
'required' => ['direction', 'confidence', 'reasoning'],
],
]));
],
]));
if (! $response->successful()) {
Log::error('GeminiPredictionProvider: 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()]);
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
}
private function prompt(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 35 days.
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
Recent Brent crude prices (USD/barrel):
{$priceList}
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['text' => $text]);
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -2,112 +2,61 @@
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\Log;
use Throwable;
class OpenAiPredictionProvider implements OilPredictionProvider
class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
{
private const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
protected function apiKey(): ?string
{
if (! config('services.openai.api_key')) {
return null;
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
return config('services.openai.api_key');
}
protected function callProvider(string $apiKey, string $priceList): ?array
{
$url = 'https://api.openai.com/v1/chat/completions';
try {
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken(config('services.openai.api_key'))
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
->withToken($apiKey)
->post($url, [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'response_format' => [
'type' => 'json_schema',
'json_schema' => [
'name' => 'oil_prediction',
'strict' => true,
'schema' => [
'type' => 'object',
'properties' => [
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'confidence' => ['type' => 'integer'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
],
],
'messages' => [[
'role' => 'user',
'content' => $this->prompt($priceList),
]],
]));
],
'messages' => [[
'role' => 'user',
'content' => $this->defaultPrompt($priceList),
]],
]));
if (! $response->successful()) {
Log::error('OpenAiPredictionProvider: 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()]);
if (! $response->successful()) {
Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
}
private function prompt(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 35 days.
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
Recent Brent crude prices (USD/barrel):
{$priceList}
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error(self::class.': unexpected response format', ['data' => $data]);
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
return null;
}
return $data;
}
}

View File

@@ -4,16 +4,31 @@ namespace App\Services;
use App\Enums\FuelType;
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;
class NationalFuelPredictionService
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
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{
* fuel_type: string,
@@ -34,19 +49,19 @@ class NationalFuelPredictionService
{
$fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null;
$context = new SignalContext($fuelType, $lat, $lng);
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->computeTrendSignal($fuelType);
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType);
$stickiness = $this->computeStickinessSignal($fuelType);
$trend = $this->trendSignal->compute($context);
$dayOfWeek = $this->dayOfWeekSignal->compute($context);
$brandBehaviour = $this->brandBehaviourSignal->compute($context);
$stickiness = $this->stickinessSignal->compute($context);
$oil = $this->oilSignal->compute($context);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $hasCoordinates
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
$regionalMomentum = $this->regionalMomentumSignal->compute($context);
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
@@ -65,6 +80,8 @@ class NationalFuelPredictionService
default => 'no_signal',
};
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [
'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg,
@@ -73,10 +90,11 @@ class NationalFuelPredictionService
'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel,
'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour),
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [
'trend' => $trend,
'day_of_week' => $dayOfWeek,
@@ -84,6 +102,7 @@ class NationalFuelPredictionService
'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness,
'oil' => $oil,
],
];
}
@@ -91,10 +110,12 @@ class NationalFuelPredictionService
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->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');
if ($avg !== null) {
@@ -107,285 +128,6 @@ class NationalFuelPredictionService
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 < 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} */
private function disabledSignal(string $detail): array
{
@@ -400,46 +142,64 @@ class NationalFuelPredictionService
}
/**
* Weighted aggregate of enabled signals.
* Returns [direction string, confidence score 0-100].
* Aggregate enabled signals into a final direction + confidence score.
*
* @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}
*/
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{
$weights = $hasCoordinates
? [
'regionalMomentum' => 0.50,
'trend' => 0.20,
'regionalMomentum' => 0.35,
'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10,
'stickiness' => 0.05,
]
: [
'trend' => 0.45,
'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25,
'brandBehaviour' => 0.15,
'stickiness' => 0.10,
];
$weightedSum = 0.0;
$totalWeight = 0.0;
$directionalScoreSum = 0.0;
$directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if ($signal && $signal['enabled']) {
$weightedSum += $signal['score'] * $signal['confidence'] * $weight;
$totalWeight += $weight;
if (! $signal || ! $signal['enabled']) {
continue;
}
$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];
}
$normalised = $weightedSum / $totalWeight;
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1);
$normalised = $directionalWeightSum > 0.01
? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) {
$normalised >= 0.1 => 'up',
@@ -447,51 +207,185 @@ class NationalFuelPredictionService
default => 'stable',
};
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore];
}
/**
* Least-squares linear regression.
* x is the array index (day number), y is the price value.
* How well the enabled 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
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
* Range: 0 (full disagreement) 1 (unanimous).
*
* @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);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
$finalDir = match ($finalDirection) {
'up' => 1,
'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;
$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];
return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
}
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 = [];
@@ -503,8 +397,16 @@ class NationalFuelPredictionService
$parts[] = $brandBehaviour['detail'];
}
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
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);

View File

@@ -6,32 +6,17 @@ use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Throwable;
final class PlanFeatures
{
/** @var string[] */
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
private Plan $plan;
private function __construct(private readonly User $user)
{
try {
$this->plan = Plan::resolveForUser($user);
} catch (Throwable) {
// Never throw — fall back to a free-tier stub if resolution fails.
$this->plan = new Plan([
'name' => 'free',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
]);
}
$this->plan = Plan::resolveForUser($user);
}
public static function for(User $user): self
@@ -47,10 +32,9 @@ final class PlanFeatures
*/
public function channelsFor(string $triggerType): array
{
$allChannels = ['email', 'push', 'whatsapp', 'sms'];
$allowed = [];
foreach ($allChannels as $channel) {
foreach (self::CHANNELS as $channel) {
if (! $this->canUseChannel($channel)) {
continue;
}
@@ -72,24 +56,7 @@ final class PlanFeatures
/** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool
{
return (bool) ($this->feature($channel, 'enabled') ?? false);
}
/** Read a nested feature value, e.g. feature('sms', 'daily_limit'). */
private function feature(string $channel, string $key): mixed
{
$features = $this->plan->features ?? [];
return $features[$channel][$key] ?? null;
}
/** 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();
return (bool) $this->plan->{"{$channel}_enabled"};
}
/**
@@ -102,9 +69,9 @@ final class PlanFeatures
return false;
}
$dailyLimit = $this->feature($channel, 'daily_limit');
$dailyLimit = $this->dailyLimit($channel);
// null or 0 in the feature means no SMS/unlimited — treat 0 as blocked, null as unlimited
// null = unlimited; 0 = blocked even though enabled
if ($dailyLimit === null) {
return true;
}
@@ -131,9 +98,6 @@ final class PlanFeatures
return true;
}
$count = $this->trackedFuelTypeCount();
// Allow if already tracking this type (not adding a new one)
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
@@ -142,15 +106,13 @@ final class PlanFeatures
return true;
}
return $count < $limit;
return $this->trackedFuelTypeCount() < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
$features = $this->plan->features ?? [];
return $features['fuel_types']['max'] ?? 1;
return $this->plan->max_fuel_types;
}
/** Count of distinct fuel types the user has preferences for. */
@@ -164,9 +126,7 @@ final class PlanFeatures
/** Generic boolean feature flag check. */
public function can(string $feature): bool
{
$features = $this->plan->features ?? [];
return (bool) ($features[$feature] ?? false);
return (bool) ($this->plan->{$feature} ?? false);
}
/** Count of notifications missed today on a channel. */
@@ -193,7 +153,7 @@ final class PlanFeatures
/** The resolved plan tier name. */
public function tier(): string
{
return $this->plan->name ?? 'free';
return $this->plan->name;
}
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
@@ -201,4 +161,23 @@ final class PlanFeatures
{
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,
};
}
}

View 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];
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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}))";
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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;
}

View 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;
}
}

View 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,
];
}
}

View 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,
];
}
}

View 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,
) {}
}

View 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,
) {}
}

View 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;
}
}

View File

@@ -72,6 +72,18 @@ return [
'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' => [

View File

@@ -11,23 +11,25 @@ use Illuminate\Database\Eloquent\Factories\Factory;
*/
class PlanFactory extends Factory
{
private static array $defaultFeatures = [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => false, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
];
public function definition(): array
{
return [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'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,
];
}
@@ -36,8 +38,8 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Free->value,
'stripe_price_id' => null,
'features' => self::$defaultFeatures,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
]);
}
@@ -45,17 +47,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Basic->value,
'stripe_price_id' => 'price_basic_test',
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
'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,
]);
}
@@ -63,17 +69,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Plus->value,
'stripe_price_id' => 'price_plus_test',
'features' => [
'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' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'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,
]);
}
@@ -81,17 +91,21 @@ class PlanFactory extends Factory
{
return $this->state(fn () => [
'name' => PlanTier::Pro->value,
'stripe_price_id' => 'price_pro_test',
'features' => [
'fuel_types' => ['max' => null],
'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,
],
'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,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?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::table('subscriptions', function (Blueprint $table): void {
$table->timestamp('current_period_start')->nullable()->after('quantity');
$table->timestamp('current_period_end')->nullable()->after('current_period_start');
$table->json('stripe_data')->nullable()->after('current_period_end');
});
}
public function down(): void
{
Schema::table('subscriptions', function (Blueprint $table): void {
$table->dropColumn(['current_period_start', 'current_period_end', 'stripe_data']);
});
}
};

View File

@@ -0,0 +1,60 @@
<?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::table('plans', function (Blueprint $table): void {
$table->unsignedTinyInteger('max_fuel_types')->nullable()->after('stripe_price_id_annual')
->comment('Null = unlimited');
$table->boolean('email_enabled')->default(true)->after('max_fuel_types');
$table->string('email_frequency', 20)->default('weekly_digest')->after('email_enabled')
->comment('weekly_digest | daily | triggered');
$table->boolean('push_enabled')->default(false)->after('email_frequency');
$table->string('push_frequency', 20)->default('none')->after('push_enabled')
->comment('none | daily | triggered');
$table->boolean('whatsapp_enabled')->default(false)->after('push_frequency');
$table->unsignedSmallInteger('whatsapp_daily_limit')->default(0)->after('whatsapp_enabled');
$table->unsignedTinyInteger('whatsapp_scheduled_updates')->default(0)->after('whatsapp_daily_limit');
$table->boolean('sms_enabled')->default(false)->after('whatsapp_scheduled_updates');
$table->unsignedSmallInteger('sms_daily_limit')->default(0)->after('sms_enabled');
$table->boolean('ai_predictions')->default(false)->after('sms_daily_limit');
$table->boolean('price_threshold')->default(false)->after('ai_predictions');
$table->boolean('score_alerts')->default(false)->after('price_threshold');
$table->dropColumn('features');
});
}
public function down(): void
{
Schema::table('plans', function (Blueprint $table): void {
$table->json('features')->after('stripe_price_id_annual');
$table->dropColumn([
'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',
]);
});
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('weekly_pump_prices', function (Blueprint $table) {
$table->date('date')->primary()->comment('Week starting (Monday) per BEIS publication');
$table->unsignedSmallInteger('ulsp_pence')->comment('Petrol pump price × 100');
$table->unsignedSmallInteger('ulsd_pence')->comment('Diesel pump price × 100');
$table->unsignedSmallInteger('ulsp_duty_pence')->comment('Petrol duty × 100');
$table->unsignedSmallInteger('ulsd_duty_pence')->comment('Diesel duty × 100');
$table->unsignedTinyInteger('ulsp_vat_pct')->comment('VAT %');
$table->unsignedTinyInteger('ulsd_vat_pct')->comment('VAT %');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('weekly_pump_prices');
}
};

View File

@@ -14,71 +14,75 @@ class PlanSeeder extends Seeder
PlanTier::Free->value => [
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'weekly_digest'],
'push' => ['enabled' => false, 'frequency' => 'none'],
'whatsapp' => ['enabled' => false, 'daily_limit' => 0, 'scheduled_updates' => 0],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
],
'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,
],
PlanTier::Basic->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.basic.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.basic.annual'),
'features' => [
'fuel_types' => ['max' => 1],
'email' => ['enabled' => true, 'frequency' => 'daily'],
'push' => ['enabled' => true, 'frequency' => 'daily'],
'whatsapp' => ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2],
'sms' => ['enabled' => false, 'daily_limit' => 0],
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
],
'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,
],
PlanTier::Plus->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.plus.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.plus.annual'),
'features' => [
'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' => 1],
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
],
'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,
],
PlanTier::Pro->value => [
'stripe_price_id_monthly' => config('services.stripe.prices.pro.monthly'),
'stripe_price_id_annual' => config('services.stripe.prices.pro.annual'),
'features' => [
'fuel_types' => ['max' => null],
'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,
],
'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,
],
];
foreach ($plans as $name => $data) {
Plan::updateOrCreate(
['name' => $name],
[
'stripe_price_id_monthly' => $data['stripe_price_id_monthly'],
'stripe_price_id_annual' => $data['stripe_price_id_annual'],
'features' => $data['features'],
'active' => true,
]
);
foreach ($plans as $name => $attributes) {
Plan::updateOrCreate(['name' => $name], [...$attributes, 'active' => true]);
}
}
}

View File

@@ -0,0 +1,618 @@
# Prediction Rebuild — Design Spec
## Context
The current prediction service (`NationalFuelPredictionService` + six signal
classes) produces output the user has repeatedly described as "doesn't make
sense": headlines that contradict their own reasoning text, weights that
nobody can defend a number on, and confidence values that aren't grounded in
any track record. Two earlier docs (`.claude/rules/scoring.md`, `.claude/rules/prediction.md`)
disagree on the weights of the same signals, which is itself evidence that
the design has drifted.
This spec replaces the entire prediction stack from scratch around the
historical data we actually have, with a model whose confidence values are
calibrated against its own backtested track record.
Goals:
- A "fill up now or wait?" call honest about uncertainty.
- Confidence values calibrated against backtested residuals — "70%" actually
means "in 7 of every 10 cases like this, the model called direction right".
- Simple enough to debug a year from now.
- Remove the six-signal aggregator entirely.
- Recognise that pump prices, while *measured* weekly by BEIS, can *move* daily
during oil shocks (Iran, OPEC surprise cuts, Hormuz disruption). The static
weekly forecast must be backed by a daily news/event overlay so we can flag
staleness in real time rather than pretend a Monday number is still valid on
Thursday after a 6% Brent move.
---
## Inputs (audited 2026-05-01)
| Source | Status | Use in v1 |
|---|---|---|
| `weekly_pump_prices` | 435 weeks, all Mondays, 0 outliers, 1 duty change (Mar 2022, 57.95p → 52.95p), VAT stable at 20% | **Foundation** — train Layer 1 |
| `station_prices_current` | ~7,550 stations × e10, ~7,620 × b7_standard | **Layer 2** — descriptive snapshot |
| `stations` | 7,747 stations, 1,989 supermarkets, lat/lng | Layer 2 |
| `station_prices` | 75 days of changes since 2026-01-16, sample mix uneven per day | Not modelled in v1, but **used by the volatility regime detector** as a churn indicator (% stations changing price / day vs 30-day baseline). |
| `brent_prices` | 30 days only | **Backfilled in Phase 7** (8 years from FRED, single API call). Used as a Brent-move volatility trigger and as fuel for the daily LLM overlay. |
The Fuel Finder API has been confirmed empirically to have **no historical
archive** — `effective-start-timestamp` is a station-level filter on current
prices, not a time-window query. Per-station deep history can only accrue
forward from the date polling started.
---
## Architecture — five thin layers
### Layer 1 — National weekly forecaster (predictive, calibrated)
Trained once weekly on `weekly_pump_prices`. Output:
- `direction ∈ {rising, falling, flat}`
- `magnitude_pence` — predicted Δ price next week
- `ridge_confidence` (0100) — calibrated from backtested residuals, not
from the model's raw output
This is the **quantitative baseline**. It updates only when the BEIS Monday
publication arrives (so the *forecast itself* changes weekly), but its
*displayed confidence* (Layer 3) is adjusted in real time by Layers 4 and 5.
`direction = flat` whenever `|magnitude_pence| < FLAT_THRESHOLD`. Phase 3
picks `FLAT_THRESHOLD` from the backtest residual distribution; the
starting value is **0.2p / litre**.
### Layer 2 — Local snapshot (descriptive, NOT predictive)
Pure SQL aggregates against `station_prices_current` + Haversine on
`stations.lat/lng`. No ML, no history, no surprises:
- `local_avg_50km(fuel_type, lat, lng)`
- `national_avg(fuel_type)`
- `cheapest_within(km, fuel_type, lat, lng)`
- `supermarket_avg_local`, `major_avg_local`, gap
Layer 2 never speaks about the future. It describes the present.
### Layer 3 — Verdict merger (rule-based gates, no multipliers)
Single user-facing verdict ∈ {`fill_now`, `wait`, `no_signal`}. The
displayed confidence number is `ridge_confidence` itself, **untouched**.
LLM agreement and volatility status are shown as separate **badges**, not
blended into the number. Honesty over smoothing.
Gates evaluated in order, first match wins:
```
1. direction == 'flat' → no_signal
2. ridge_confidence < 40 no_signal
3. volatility_regime active → no_signal (badge: volatile)
4. LLM disagrees AND ridge_confidence < 75 no_signal (badge: conflicting)
5. rising AND ridge_confidence >= 70 → fill_now
6. falling AND ridge_confidence >= 70 → wait
7. otherwise (40 <= conf < 70, no veto from 3 or 4) dashboard-only
```
Why gates, not multipliers:
- A multiplied confidence number is a black-box blend that the user can't
audit. A 70% that used to be 90% before today's volatility hit looks
identical to a 70% that's been calibrated all along.
- Gates compose cleanly. Each rule has one job and is independently
testable.
- The verdict is binary anyway (notify / don't / silent). Smoothing
confidence under the hood doesn't help that decision — it only obscures it.
Layer 2 affects **urgency wording only** ("fill up now, *especially* in
your area at 2p above national"). It never changes the verdict. Neither
does Layer 4 or Layer 5 — they can suppress (gate 3, 4) but never flip
the direction.
### Layer 4 — Daily LLM news overlay (qualitative, news-aware)
**Single scheduled call at 07:00 UK.** Plus an event-driven refresh when
Layer 5's volatility flag flips ON (with a 4-hour cooldown so the same
event doesn't trigger repeatedly).
JSON in, JSON out. Calls Claude Haiku with web search enabled, asks for
direction + confidence + cited events with URLs. Stored in a new
`llm_overlays` table.
Layer 4 is **read-only with respect to the volatility flag**. It writes
its result row; only Layer 5 mutates `volatility_regimes.active`.
LLM confidence is hard-capped at 75 in code (web-searched LLMs are
systematically overconfident). Calls without `events_cited` are rejected.
### Layer 5 — Volatility regime detector (intra-week safety net)
Hourly cron. **Sole owner** of the `volatility_regimes.active` flag.
Reads four signals, OR-combined:
1. Daily Brent move > 3% close-to-close (FRED `DCOILBRENTEU`, Phase 7).
2. Most recent `llm_overlays.major_impact_event = true` AND at least one
verified URL.
3. `station_prices` daily churn rate > 1.5× its 30-day baseline.
4. A `watched_events` row covering today (manually flagged geopolitical
periods).
When the flag flips on:
- An event-driven LLM refresh is queued (Layer 4) if last run was > 4h ago.
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
`volatile` badge.
- The reasoning text appended: *"Volatility detected ({trigger}) — this
forecast may be stale within days."*
When it flips off:
- Verdict returns to whatever the gates produce on the unchanged
`ridge_confidence` (no multiplier to reset — there are none).
- Badge cleared.
- Next morning's 07:00 LLM call still runs (it always runs); no extra
refreshes are queued.
Layer 5 never changes Layer 1's *direction*. It only suppresses the
verdict via gate 3.
---
## Methodology — Layer 1
### Target
```
ΔULSP[t+1] = ULSP[t+1] ULSP[t]
```
We model the **change**, not the level. UK pump prices are non-stationary,
so regressing on levels gives spurious R² and useless coefficients.
Differencing makes the series stationary.
### Features (all stationary)
| Feature | Notes |
|---|---|
| `Δulsp_lag_0`, `Δulsp_lag_1`, `Δulsp_lag_3` | 1w / 2w / 4w momentum |
| `Δulsd_lag_0` | Diesel cross-signal as a *change* |
| `ulsp[t] ma8[t]` | **Mean-reversion term** — gap between current price and 8-week MA. Single most useful feature for 1-week-ahead UK pump forecast. |
| `week_of_year_sin`, `week_of_year_cos` | Cyclic seasonality encoding |
| `is_pre_bank_holiday` | Boolean, within 7 days of UK bank holiday |
The level only enters as the deviation from MA-8 (itself stationary).
That's the only way levels are allowed in.
**Duty change is NOT a feature.** With one event in 435 weeks, n=1 cannot
fit a meaningful coefficient. Instead, duty-change-adjacent weeks (±4
weeks of a known change) are handled in the **calibration override**
(see below) — confidence is halved and the regime flag is surfaced in
the reasoning text. A regime can be flagged. A coefficient cannot be
trained from one observation.
### Model
Ridge regression. Boring on purpose:
- 435 weekly observations is too few to beat a well-specified linear model
out-of-sample with gradient boosting or LSTM — those would just fit noise.
- Interpretable coefficients are essential for the honesty layer
(the reasoning text describes what the model used).
Upgrade to a non-linear model **only** if Phase 3 backtest demonstrates the
linear model is missing real structure.
### Training and evaluation split
- Train on weeks 1305 (~70%).
- Evaluate on weeks 306435 (~30%) with rolling-origin cross-validation
(single-split would overfit hyperparameters to one window).
### Confidence calibration
Two-stage calibration:
1. **Magnitude binning** — bin predictions by predicted `|magnitude|` and
record actual hit rate per bin. The published `confidence_score` reads
from this lookup, not from the model's raw output.
2. **Regime flag** — flag any forecast week within ±4 weeks of a known
duty change. With only one duty change in 435 weeks, statistical
stratification at n=1 is impossible. Instead:
- For flagged weeks, halve the calibrated confidence manually.
- Surface the flag in the reasoning text: *"Recent duty change —
forecast accuracy is reduced for the next several weeks."*
This is the only place v1 accepts a hand-tuned guard, and it's there
because the data can't tell us better.
---
## Methodology — Layer 2
Pure aggregates. No model.
```sql
-- Local 50km average
SELECT AVG(price_pence) FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 50km of (lat, lng)>;
-- National average
SELECT AVG(price_pence) FROM station_prices_current WHERE fuel_type = ?;
-- Cheapest within 25km
SELECT stations.*, station_prices_current.price_pence
FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 25km>
ORDER BY price_pence ASC LIMIT 5;
-- Supermarket vs major split, locally
SELECT stations.is_supermarket, AVG(price_pence)
FROM station_prices_current
JOIN stations ON station_prices_current.station_id = stations.node_id
WHERE fuel_type = ? AND <Haversine within 25km>
GROUP BY stations.is_supermarket;
```
Output is descriptive: "Your area is X p above national average right
now", "Cheapest near you: {station} at {price}", "Supermarkets near you:
{avg} vs majors: {avg}". **Never** predictive language.
---
## Methodology — Layer 3
Full gate ordering is in the Architecture section (Layer 3). Summary:
- Verdict via ordered rule gates, **not** multipliers.
- `ridge_confidence` is displayed verbatim — never multiplied.
- Volatility flag and LLM disagreement act as **suppressors with badges**
(`volatile`, `conflicting`) but never flip direction.
- `direction == 'flat'` always produces `no_signal`.
- LLM disagreement only suppresses the verdict when `ridge_confidence < 75`.
Above 75 the model's call is strong enough to stand even with a news-scan
disagreement (the LLM is hard-capped at 75 confidence anyway, so it
can't out-confidence the ridge model — only flag a tension).
Local position from Layer 2 modifies urgency wording only:
- If user's local average is materially above national (>2p), and Layer 1
says "rising", urgency increased ("fill up now, *especially* in your area").
- Layer 2 never flips Layer 1's direction.
---
## Methodology — Layer 4 (LLM news overlay)
Single scheduled call daily at 07:00 UK. Additional event-driven calls
are queued by Layer 5 when the volatility flag flips ON, with a 4-hour
cooldown enforced in code (skip the queue if the most recent
`llm_overlays.ran_at` is within 4 hours).
**Brent input** (`brent_recent_14_days`) is optional — passed as `null`
until Phase 7 backfills `brent_prices`. Phase 8 cannot ship before
Phase 7 — explicit dependency.
### Request shape (JSON)
```json
{
"input": {
"ulsp_recent_8_weeks": [...],
"brent_recent_14_days": [...],
"current_week_of_year": 18,
"days_to_next_bank_holiday": 5,
"duty_pence": 52.95,
"ridge_model_says": {
"direction": "down",
"confidence": 68,
"magnitude_pence": -0.4
}
},
"ask": "Search recent news for oil-supply, OPEC, refinery, shipping, sanctions, geopolitical events affecting UK retail fuel prices over the next 1-2 weeks. Reply ONLY in the schema below."
}
```
### Response shape (JSON, enforced)
```json
{
"direction": "rising | falling | flat",
"confidence": 0,
"reasoning_short": "1-2 sentences",
"events_cited": [
{"headline": "...", "source": "...", "url": "...", "impact": "rising|falling|neutral"}
],
"agrees_with_ridge": true,
"major_impact_event": false
}
```
### Code-level guards (not in the prompt)
1. **Cap `confidence` at 75.** Web-searched LLMs are systematically overconfident.
2. **Reject the response if `events_cited` is empty.** Forces the LLM to
ground its call in something checkable, not vibes.
3. **Verify each `url` in `events_cited` is reachable** before storing.
Catches hallucinated citations. Failed URLs blank the citation but
don't reject the call (newer URLs sometimes 404 briefly).
4. **Layer 4 does NOT mutate `volatility_regimes.active`.** It writes its
row to `llm_overlays` (with `major_impact_event` + verified URLs) and
that's it. Layer 5's hourly cron picks up the new row and decides
whether to flip the flag.
### How Layer 3 uses it
- LLM agrees → no gating effect; `agrees` badge shown next to the verdict
("News scan agrees, citing {event}").
- LLM disagrees AND `ridge_confidence < 75`**gate 4 fires**: verdict
forced to `no_signal` with the `conflicting` badge.
- LLM disagrees AND `ridge_confidence >= 75` → no suppression; the
disagreement is shown as a badge but the model's strong call stands.
- LLM neutral / flat → no gating effect.
- Direction is never flipped by the LLM.
---
## Methodology — Layer 5 (volatility regime detector)
Hourly cron. **Sole owner** of `volatility_regimes.active`. Reads four
signals, OR-combined:
1. **Brent move** — close-to-close daily Brent move > 3% on FRED
`DCOILBRENTEU`. FRED publishes with a one-day lag (today's value is
yesterday's settle), so the trigger reflects the most recent settled
day. Sufficient for v1 — we don't have a real-time Brent feed.
2. **LLM major-impact flag** — most recent `llm_overlays` row has
`major_impact_event = true` AND at least one verified URL.
3. **Station churn***gated until ≥180 days of stable polling.* The
trigger fires when the last-24h % of stations updating price exceeds
1.5× the 30-day rolling baseline. With only 75 days of uneven polling
(Jan 16 → May 1) the baseline is meaningless — sample-mix variance
would dominate any real shock signal. The trigger is implemented but
disabled in code via a feature flag; flip it on once `station_prices`
has 180+ continuous days.
4. **Manual `watched_events`** — a row covering today. Lets you flag
known geopolitical periods manually (e.g. "Iran tensions AprMay 2026").
When the flag flips on:
- An event-driven Layer 4 LLM refresh is queued (skipped if the most
recent `llm_overlays.ran_at` is within 4 hours — cooldown).
- **Layer 3's gate 3 fires**: verdict forced to `no_signal` with the
`volatile` badge for as long as the flag stays on.
- Reasoning text appended: *"Volatility detected ({trigger label}) — this
forecast may be stale within days."*
When it flips off:
- Verdict returns to whatever the gates produce on the unchanged
`ridge_confidence` (no multiplier reset needed — there are no multipliers).
- Badge cleared.
- The next morning's 07:00 LLM call still runs (always does); no extra
refreshes are queued by Layer 5.
---
## Schema deltas
### Add
```
weekly_forecasts
id BIGINT PK
forecast_for DATE — Monday the forecast covers
model_version VARCHAR(32) — links back to backtests row
direction ENUM('rising','falling','flat')
magnitude_pence SMALLINT — predicted Δ × 100, signed
ridge_confidence TINYINT UNSIGNED — 0..100, calibrated from backtested residuals. Displayed verbatim. Layer 3 gates may suppress the verdict but never modify this number.
flagged_duty_change BOOLEAN — true if forecast is within ±4 weeks of a duty change (avoids collision with Layer 5's volatility_regimes)
reasoning TEXT — generated from features actually used
generated_at DATETIME
UNIQUE (forecast_for, model_version)
INDEX (forecast_for, generated_at DESC)
forecast_outcomes
forecast_for DATE
model_version VARCHAR(32)
predicted_class ENUM('rising','falling','flat')
actual_class ENUM('rising','falling','flat')
correct BOOLEAN
abs_error_pence SMALLINT UNSIGNED
resolved_at DATETIME
PRIMARY KEY (forecast_for, model_version)
backtests
id BIGINT PK
model_version VARCHAR(32) UNIQUE
features_json JSON — feature spec
train_start DATE
train_end DATE
eval_start DATE
eval_end DATE
directional_accuracy DECIMAL(5,2)
mae_pence DECIMAL(5,2)
calibration_table JSON — {bin_low..bin_high → empirical_hit_rate}
leak_suspected BOOLEAN — secondary smell test: true if directional_accuracy > 75. Primary leak detection is structural (see Backtest section).
ran_at DATETIME
llm_overlays
id BIGINT PK
ran_at DATETIME
forecast_for_week DATE — which weekly forecast it overlays
direction ENUM('rising','falling','flat')
confidence TINYINT UNSIGNED — capped 75 in code
reasoning TEXT
events_json JSON — cited events with verified URLs
agrees_with_ridge BOOLEAN
major_impact_event BOOLEAN
volatility_flag_on BOOLEAN — was the regime flag on at run time
search_used BOOLEAN
INDEX (forecast_for_week, ran_at)
volatility_regimes
id BIGINT PK
flipped_on_at DATETIME
flipped_off_at DATETIME NULL
trigger ENUM('brent_move','llm_event','station_churn','manual')
trigger_detail TEXT — e.g. "Brent +4.2% close-to-close"
active BOOLEAN
watched_events
id BIGINT PK
label VARCHAR(128)
starts_at DATETIME
ends_at DATETIME
notes TEXT
```
### Keep
- `weekly_pump_prices` — already loaded, source of truth
- `stations`, `station_prices_current` — for Layer 2
- `station_prices` — keep collecting forward, not modelled in v1
### Deprecate (delete after Layer 1 ships)
- `price_predictions` — old LLM/EWMA store, replaced by `weekly_forecasts`
The current six-signal aggregator (`NationalFuelPredictionService` and
`app/Services/Prediction/Signals/*`) is **fully replaced**, not extended.
Same JSON output keys (`predicted_direction`, `confidence_score`,
`action`, `reasoning`) so the Vue frontend doesn't break — engine swapped,
contract preserved.
---
## Implementation phases (each ships something working)
| Phase | Scope | Ships |
|---|---|---|
| **1. Backtest harness** | `BacktestRunner` service + `backtests` table. Takes a model class, train/eval split, returns directional accuracy + MAE + calibration curve. **Structural leak detection** built in (per-feature source-timestamp check vs target Monday); accuracy>75% smell test as secondary. | A way to *prove* any future model works before shipping it. |
| **2. Naive baseline** | "Predict next week = this week" implemented as a model class. Run through harness. | A floor: any future model must beat this. |
| **3. v1 ridge model** | Features above (incl. mean-reversion term), trained once, persisted with `model_version`. `WeeklyForecastService` runs it. Backtest must clear the acceptance gate. | First real forecast. Backtested numbers visible. |
| **4. Live wiring** | Replace `NationalFuelPredictionService` internals with a thin adapter delegating to `WeeklyForecastService`. Same API shape, new engine. | Frontend keeps working, predictions now from the new model. |
| **5. Local snapshot** | `LocalSnapshotService` — pure aggregates. Wire into `/api/stations` payload alongside the headline forecast. | "Your area" descriptive cards. |
| **6. Honesty layer** | Reasoning generator describes *what the model used*: lag values, season, holiday flag. Shows backtest accuracy badge. Returns explicit "not enough data" when confidence < 40. Surfaces the duty-change-adjacent flag when set. | The "no BS" framing. |
| **7. Brent backfill + daily refresh** | One FRED call (2018→today, ~2,150 daily rows). Daily refresh cron at **06:30 UK** (must complete before Phase 8's 07:00 LLM call — sequenced so the LLM has fresh Brent context). Used by Phase 9's volatility detector and as a feature option for future model iterations (only added to the ridge model if backtested lift is ≥3 percentage points directional accuracy). | Daily Brent in DB. Foundation for volatility + LLM context. |
| **8. LLM news overlay** | `LlmOverlayService` — single scheduled call at **07:00 UK** (after Brent refresh). Plus event-driven calls when Layer 5 flips the volatility flag on, with 4h cooldown. JSON in / JSON out, web search enabled, results stored in `llm_overlays`. Feeds Layer 3's gate 4 (suppress when LLM disagrees AND ridge_confidence < 75) and the `agrees`/`conflicting` badges. URL-verification + empty-citation rejection enforced in code. **Depends on Phase 7.** | News-aware verdict suppression and badge on top of the calibrated ridge baseline. |
| **9. Volatility regime detector** | `VolatilityRegimeService` — hourly cron, sole owner of `volatility_regimes.active`. OR-combines four triggers: Brent move > 3%, LLM `major_impact_event`, station churn > 1.5× baseline (**gated until ≥180 days of stable polling**), `watched_events` row covering today. Fires Layer 3's gate 3 (verdict → `no_signal` with `volatile` badge) and the event-driven Layer 4 refresh. | The intra-week safety net for oil shocks. |
---
## Backtest acceptance gates (Phase 3 → Phase 4)
| Backtest result | Action |
|---|---|
| < 60% directional accuracy | Features are wrong. Stay in Phase 3, don't ship. |
| 6062% | Marginal. One feature iteration, then re-evaluate. |
| **6268%** | **Ship.** Realistic target for UK weekly pump direction without Brent. |
| 6875% | Excellent. Ship and watch closely. |
| > 75% | **Stop.** Run the structural leak detector. Almost certainly time leakage (e.g. using `t+1` info accidentally in `t` features). The accuracy threshold is a secondary smell test, not the primary detector. |
| MAE > 1.0p / litre | Features are noisy. Refit before shipping. |
| Target MAE | 0.40.7p / litre. |
### Structural leak detection (primary)
Built into the backtest harness. For every (training_week, feature_value)
pair, the harness verifies the data source's effective timestamp is
**strictly before** the target Monday. Any feature whose source timestamp
is on or after the target week is treated as leakage and the backtest
fails fast. This is independent of accuracy — it catches leakage even
when it doesn't translate into suspiciously high accuracy.
The `> 75% accuracy` row is a secondary smell test for leakage modes the
structural check missed (e.g. label leakage via a downstream computed
column). Primary defence is the timestamp check. These numbers are
encoded in the harness as assertions, not aspirations.
---
## Honesty rules — non-negotiables
1. Backtest accuracy is **published in the UI**. The model wears its track
record on its sleeve.
2. Below 40 confidence, the recommendation is `no_signal` and the reasoning
says "we don't have enough signal to call it" — explicitly. No filler.
3. When duty-change-adjacent weeks affect the forecast, surface the flag
("forecast may be skewed by recent duty change").
4. Reasoning text only references features the model actually used — no
narrative invention. If the mean-reversion term drove the call, say so
("Pump prices are 3.1p above their 8-week average, and prices typically
pull back from that level"). If the seasonality term drove it, say so.
5. `forecast_outcomes` is populated automatically when the next BEIS week
lands. Hit rate over the trailing 13 weeks is shown next to the headline.
6. When the **volatility regime flag** is on, the UI shows the `volatile`
badge and the trigger (e.g. "Brent up 4.2% yesterday — forecast may be
stale within days"). Verdict is suppressed visibly via gate 3, never
silently.
7. The LLM overlay is **shown separately** from the ridge model, never
blended. "Model says down (68%); news scan agrees, citing {event}" —
the `ridge_confidence` number stays calibrated and untouched, while
LLM and volatility status are presented as their own badges.
8. LLM citations with unreachable URLs are **dropped from the displayed
reasoning** but kept in `llm_overlays.events_json` for audit. We never
show a citation we haven't verified.
---
## What gets deleted at the end of Phase 4
- `app/Services/Prediction/Signals/*` (whole directory)
- `NationalFuelPredictionService` internals (kept as a thin wrapper, then
renamed when the frontend migration completes)
- `price_predictions` table — replaced by `weekly_forecasts` (ridge) +
`llm_overlays` (news layer)
- `OilPriceService::generatePrediction()`, EWMA/LLM helpers — replaced by
`LlmOverlayService` (Phase 8) which has a different contract
- `OilPriceService::fetchBrentPrices()` — kept and **expanded** in Phase 7
(backfill mode + daily refresh), not deleted
- `.claude/rules/scoring.md` retired in favour of a fresh
`.claude/rules/forecasting.md`
- `.claude/rules/prediction.md` rewritten to match the new architecture
---
## Open decisions (to confirm before Phase 1)
- **Forecast cadence** — the *forecast itself* is weekly (matches BEIS
publication). The *confidence and presentation* update daily via Layer 4
(LLM) and Layer 5 (volatility regime). This split is deliberate — we
refuse to fabricate intra-week movement, but we don't pretend a static
Monday number is reliable on Thursday after a 6% Brent move.
- **Scope** — drop the six-signal aggregator entirely, confirmed.
- **API shape** — keep existing JSON output keys so Vue keeps working,
with the engine swapped under the hood. The original `confidence_score`
field maps to `ridge_confidence` (calibrated, untouched). Add new
fields: `volatility` (`{active, trigger}`), `news_overlay`
(`{direction, agreement, events}`), and `verdict_reason` (which gate
fired, if any). The verdict itself goes in the existing `action` field.
- **Brent** — promoted to Phase 7 (was "optional, conditional"). Needed
for the volatility detector, regardless of whether it's used in the
ridge model.
- **LLM** — Anthropic Claude Haiku with web search. Single scheduled call
at 07:00 UK (after the 06:30 Brent refresh). Plus event-driven refreshes
when Layer 5 flips the volatility flag on, with a 4h cooldown. No fixed
afternoon cron — by 13:00 UK, morning users have already made their
fill-up decisions, so the value is too low to justify the extra noise.
Hard confidence cap 75. Empty-citation rejection.
---
## Changelog (substantive design decisions)
| When | Change | Why |
|---|---|---|
| 2026-05-01 v1 | Initial spec — three layers, six-signal aggregator removed, ridge model on BEIS weekly data | Replace incoherent `NationalFuelPredictionService` |
| 2026-05-01 v2 | Added Layer 4 (LLM news overlay) and Layer 5 (volatility regime detector). Pump prices can move daily during oil shocks; static weekly forecast must be backed by intra-week safety nets. | Iran/Hormuz-style shocks make a Monday-only confidence number stale by Wednesday |
| 2026-05-01 v3 | **Verdict via rule gates, not multipliers.** `ridge_confidence` displayed verbatim. LLM and volatility presented as badges. `weeks_since_duty_change` removed from features (kept as calibration override only — n=1 can't fit a coefficient). Backtest gate floor lowered 65 → 62 (realistic without Brent). Structural leak detection (per-feature timestamp check) made primary; accuracy>75% demoted to secondary smell test. `weekly_forecasts` PK changed to `(forecast_for, model_version)` to preserve audit on retrain. `forecast_outcomes` made three-class. Layer 5 station-churn trigger gated until ≥180 days of stable polling. | Multipliers obscure calibration. Gates compose cleanly and stay auditable. |
---
## References
- Alquist, Kilian, Vigfusson (2013) — *Forecasting the Price of Oil*
the academic basis for "no-change baseline beats most structural models
at <6m horizons" (which is why Phase 2 matters as a hard floor).
- BEIS *Weekly road fuel prices* CSV — the 435-week training set.
- `.claude/rules/scoring.md`, `.claude/rules/prediction.md` — the two
inconsistent rule files this spec replaces.

View File

@@ -1,74 +1,41 @@
<template>
<div class="relative">
<!-- Gated overlay for free/guest users -->
<div>
<!-- Loading state -->
<div
v-if="!isPaidTier"
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
v-if="loading"
class="p-6 bg-white rounded-2xl border border-zinc-300 animate-pulse space-y-2"
>
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
<div class="h-4 bg-zinc-200 rounded w-1/3"></div>
<div class="h-6 bg-zinc-200 rounded w-2/3"></div>
</div>
<!-- Free / guest: compact one-liner -->
<div
v-else-if="!isPaidTier"
class="flex items-center gap-3 px-4 py-3 bg-white rounded-2xl border border-zinc-300"
>
<div :class="['shrink-0 w-10 h-10 rounded-full flex items-center justify-center', accentBg]">
<iconify-icon :icon="genericIcon" class="text-xl text-white"></iconify-icon>
</div>
<p class="flex-1 text-sm text-zinc-800 font-medium leading-snug">
{{ genericSentence }}
</p>
<a
class="hidden sm:inline-flex shrink-0 text-sm font-bold text-accent hover:text-accent-content whitespace-nowrap"
href="/pricing"
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
>
Upgrade from £0.99/mo
See full prediction
</a>
</div>
<!-- Card content (blurred for free users, fully visible for paid) -->
<div
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
>
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
<!-- Loading state -->
<template v-if="loading">
<div class="animate-pulse space-y-2">
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
</div>
</template>
<!-- Loaded state -->
<template v-else-if="prediction">
<h3
class="text-2xl font-black"
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
>
{{ actionLabel }}
</h3>
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
:style="{ width: prediction.confidence_score + '%' }"
></div>
</div>
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
<div class="flex items-center gap-4 text-xs text-zinc-500 font-medium">
<span>Avg: {{ prediction.current_avg }}p</span>
<span>Confidence: {{ prediction.confidence_label }}</span>
<span v-if="prediction.predicted_change_pence">
{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected
</span>
</div>
</template>
<!-- Empty state (placeholder for gated view) -->
<template v-else>
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
<p class="text-sm text-zinc-500">Prices in your area are rising best to fill up today.</p>
</template>
</div>
<!-- Paid: full prediction -->
<PredictionFull v-else :prediction="prediction" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import PredictionFull from './PredictionFull.vue'
const props = defineProps({
prediction: { type: Object, default: null },
@@ -76,12 +43,23 @@ const props = defineProps({
isPaidTier: { type: Boolean, default: false },
})
const actionLabel = computed(() => {
if (!props.prediction) return ''
return {
fill_now: 'Fill up now',
wait: 'Wait — prices falling',
no_signal: 'No clear signal',
}[props.prediction.action] ?? 'Check local prices'
})
const direction = computed(() => props.prediction?.predicted_direction ?? 'stable')
const genericSentence = computed(() => ({
up: 'UK fuel prices are trending upward this week.',
down: 'UK fuel prices have been falling this week.',
stable: 'UK fuel prices have been steady this week.',
})[direction.value] ?? 'UK fuel prices have been steady this week.')
const genericIcon = computed(() => ({
up: 'lucide:trending-up',
down: 'lucide:trending-down',
stable: 'lucide:minus',
})[direction.value] ?? 'lucide:minus')
const accentBg = computed(() => ({
up: 'bg-mauve',
down: 'bg-teal',
stable: 'bg-tan',
})[direction.value] ?? 'bg-tan')
</script>

View File

@@ -0,0 +1,93 @@
<template>
<div
v-if="prediction"
class="p-4 sm:p-5 bg-white rounded-2xl border border-zinc-300"
>
<div class="grid gap-4 lg:grid-cols-2 lg:gap-5">
<div class="space-y-1.5">
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Price Prediction</p>
<h3 class="text-sm font-semibold text-zinc-800 leading-snug">{{ actionLabel }}</h3>
<p class="text-sm text-zinc-500 leading-snug">{{ prediction.reasoning }}</p>
<p class="text-sm text-zinc-500 leading-snug">
<span>Avg {{ prediction.current_avg }}p</span>
<span class="text-zinc-400"> · </span>
<span>Confidence {{ prediction.confidence_label }}</span>
<template v-if="prediction.predicted_change_pence">
<span class="text-zinc-400"> · </span>
<span>{{ prediction.predicted_change_pence > 0 ? '+' : '' }}{{ prediction.predicted_change_pence.toFixed(1) }}p expected</span>
</template>
</p>
</div>
<div
v-if="weeklyHeadline || todayContext"
class="space-y-1.5 pt-3 border-t border-zinc-200 lg:pt-0 lg:border-t-0 lg:border-l lg:pl-5"
>
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Last 7 days</p>
<p v-if="weeklyHeadline" class="text-sm font-semibold text-zinc-800 leading-snug">{{ weeklyHeadline }}</p>
<p v-if="todayContext" class="text-sm text-zinc-500 leading-snug">{{ todayContext }}</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
prediction: { type: Object, default: null },
})
const actionLabel = computed(() => {
if (!props.prediction) return ''
return {
fill_now: 'Fill up now',
wait: 'Wait — prices falling',
no_signal: 'No clear signal',
}[props.prediction.action] ?? 'Check local prices'
})
const weekly = computed(() => props.prediction?.weekly_summary ?? null)
function formatPence(value) {
if (value === null || value === undefined) return null
return Number(value).toFixed(1) + 'p'
}
function formatDateShort(iso) {
if (!iso) return ''
const d = new Date(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric' })
}
const weeklyHeadline = computed(() => {
const w = weekly.value
if (!w || !w.cheapest_day || !w.priciest_day || w.last_7_days_change_pence === null) {
return null
}
const change = w.last_7_days_change_pence
const lead = change > 0.05
? `Avg rose ${change.toFixed(1)}p`
: change < -0.05
? `Avg fell ${Math.abs(change).toFixed(1)}p`
: 'Avg held steady'
return `${lead} — cheapest ${formatDateShort(w.cheapest_day.date)} (${formatPence(w.cheapest_day.avg)}), priciest ${formatDateShort(w.priciest_day.date)} (${formatPence(w.priciest_day.avg)}).`
})
const todayContext = computed(() => {
const w = weekly.value
if (!w) return null
const today = formatPence(w.today_avg)
const tomorrow = formatPence(w.tomorrow_estimated_avg)
if (today && tomorrow) {
return `Today ${today}; tomorrow ≈ ${tomorrow}.`
}
if (today) {
return `Today ${today}.`
}
return null
})
</script>

View File

@@ -57,7 +57,7 @@
leave-to-class="opacity-0"
>
<div v-if="expanded" class="border-t border-zinc-200 pt-3 space-y-3">
<p v-if="brandLabel" class="text-[10px] font-black uppercase tracking-widest text-zinc-500">
<p v-if="brandLabel" class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ brandLabel }}
</p>

View File

@@ -38,12 +38,22 @@
</section>
<section v-if="outdated.length" class="space-y-2 pt-4">
<header class="flex items-center gap-2">
<button
:aria-expanded="outdatedOpen"
class="flex items-center gap-2 w-full text-left py-3 px-3 rounded-lg hover:bg-zinc-100/60 transition-colors"
type="button"
@click="outdatedOpen = !outdatedOpen"
>
<iconify-icon class="text-status-bad text-lg" icon="lucide:triangle-alert"></iconify-icon>
<h3 class="font-black text-zinc-800">Outdated</h3>
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate</span>
</header>
<div class="opacity-60">
<span class="text-xs text-zinc-500 font-medium">Over 7 days old likely inaccurate ({{ outdated.length }})</span>
<iconify-icon
:class="{ 'rotate-180': outdatedOpen }"
class="text-zinc-500 text-base ml-auto transition-transform"
icon="lucide:chevron-down"
></iconify-icon>
</button>
<div v-if="outdatedOpen" class="opacity-60">
<StationCard
v-for="station in outdated"
:key="station.station_id"
@@ -72,7 +82,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import StationCard from './StationCard.vue'
const props = defineProps({
@@ -85,6 +95,8 @@ const reliable = computed(() => props.stations.filter(s => s.reliability === 're
const stale = computed(() => props.stations.filter(s => s.reliability === 'stale'))
const outdated = computed(() => props.stations.filter(s => s.reliability === 'outdated'))
const outdatedOpen = ref(false)
const lowestPrice = computed(() => {
if (!reliable.value.length && !props.stations.length) return null
const pool = reliable.value.length ? reliable.value : props.stations

View File

@@ -0,0 +1,52 @@
<template>
<div
v-if="!isPaidTier"
class="relative overflow-hidden rounded-2xl border border-accent/30 bg-gradient-to-r from-accent/10 via-accent/5 to-transparent p-5 sm:p-6"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex items-start gap-4">
<div class="shrink-0 w-11 h-11 rounded-2xl bg-accent text-white flex items-center justify-center shadow-md">
<iconify-icon class="text-xl" icon="lucide:sparkles"></iconify-icon>
</div>
<div class="space-y-1">
<h3 class="text-lg sm:text-xl font-black text-zinc-800 leading-tight">
Stop guessing. Get a buy-or-wait alert before every fill-up.
</h3>
<p class="text-sm text-zinc-500">
14-day predictions + daily price-drop alerts across
<span class="font-bold text-zinc-800">{{ stationCountLabel }}</span> UK stations.
From <span class="font-bold text-zinc-800">£0.99/mo</span>.
</p>
</div>
</div>
<a
:href="ctaHref"
class="shrink-0 inline-flex items-center justify-center gap-2 px-5 py-3 rounded-xl bg-accent text-white text-sm font-black shadow-lg hover:bg-accent-content transition-colors"
>
{{ ctaLabel }}
<iconify-icon class="text-base" icon="lucide:arrow-right"></iconify-icon>
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useAuth } from '../composables/useAuth.js'
const props = defineProps({
stationCount: { type: Number, default: null },
})
const { isAuthenticated, isPaidTier } = useAuth()
const stationCountLabel = computed(() => {
if (!props.stationCount) {
return '14,500+'
}
return new Intl.NumberFormat('en-GB').format(props.stationCount)
})
const ctaHref = computed(() => isAuthenticated.value ? '#pricing' : '/register?tier=plus&cadence=monthly')
const ctaLabel = computed(() => isAuthenticated.value ? 'See plans' : 'Start saving')
</script>

View File

@@ -19,6 +19,22 @@ export function useAuth() {
return ['basic', 'plus', 'pro'].includes(userTier.value)
})
const subscriptionCancelled = computed(() => {
return user.value?.subscription_cancelled ?? false
})
const subscriptionCadence = computed(() => {
return user.value?.subscription_cadence ?? null
})
const subscribedAt = computed(() => {
return user.value?.subscribed_at ?? null
})
const subscriptionExpiresAt = computed(() => {
return user.value?.subscription_expires_at ?? null
})
async function fetchUser() {
if (fetched.value) {
return
@@ -68,6 +84,10 @@ export function useAuth() {
isAuthenticated,
userTier,
isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
fetchUser,
clearUser,
logout,

View File

@@ -1,31 +0,0 @@
import { ref } from 'vue'
import api from '../axios.js'
export function usePrediction() {
const prediction = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetch({ lat, lng } = {}) {
loading.value = true
error.value = null
prediction.value = null
const params = {}
if (lat && lng) {
params.lat = lat
params.lng = lng
}
try {
const response = await api.get('/prediction', { params })
prediction.value = response.data
} catch (err) {
error.value = 'Unable to load prediction.'
} finally {
loading.value = false
}
}
return { prediction, loading, error, fetch }
}

View File

@@ -4,6 +4,7 @@ import api from '../axios.js'
export function useStations() {
const stations = ref([])
const meta = ref(null)
const prediction = ref(null)
const loading = ref(false)
const error = ref(null)
@@ -12,6 +13,7 @@ export function useStations() {
error.value = null
stations.value = []
meta.value = null
prediction.value = null
const params = { fuel_type: fuelType, radius, sort }
@@ -26,6 +28,7 @@ export function useStations() {
const response = await api.get('/stations', { params })
stations.value = response.data.data
meta.value = response.data.meta
prediction.value = response.data.prediction ?? null
} catch (err) {
error.value = err.response?.data?.errors
?? { general: ['Unable to load stations. Please try again.'] }
@@ -37,9 +40,10 @@ export function useStations() {
function reset() {
stations.value = []
meta.value = null
prediction.value = null
error.value = null
loading.value = false
}
return { stations, meta, loading, error, search, reset }
return { stations, meta, prediction, loading, error, search, reset }
}

View File

@@ -36,6 +36,13 @@
<section v-if="searchAttempted" id="searchAttempted" class="px-6">
<div class="max-w-7xl mx-auto space-y-6">
<!-- Prediction box (sits above filter results) -->
<PredictionCard
:is-paid-tier="showFullPrediction"
:loading="loading"
:prediction="prediction"
/>
<!-- Post-search filter bar -->
<PostSearchFilters
v-model:brand-filter="brandFilter"
@@ -74,6 +81,7 @@
:radius-miles="radiusMiles"
:stations="filteredStations"
/>
<UpsellBanner :station-count="liveStats.stationCount" />
<StationList
:current-sort="sort"
:origin="searchOrigin"
@@ -201,7 +209,7 @@
</div>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
<!-- Free -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
@@ -229,7 +237,7 @@
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Buy-or-Wait Score</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
</ul>
@@ -253,23 +261,6 @@
</ul>
<a :href="ctaHref('plus')" class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all">{{ ctaLabel('plus') }}</a>
</div>
<!-- Pro -->
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">{{ PRICES[cadence].pro }}</span>
<span class="text-zinc-400 text-sm">{{ PRICE_SUFFIX[cadence] }}</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
</ul>
<a :href="ctaHref('pro')" class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors">{{ ctaLabel('pro') }}</a>
</div>
</div>
</div>
</section>
@@ -316,13 +307,12 @@
</section>
<!-- CTA -->
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center">
<section class="py-12 md:py-24 px-6 bg-accent text-white text-center" v-if="!isAuthenticated">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
</div>
</div>
</section>
@@ -394,7 +384,9 @@ import { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js'
import api from '../axios.js'
import PostSearchFilters from '../components/PostSearchFilters.vue'
import PredictionCard from '../components/PredictionCard.vue'
import StationList from '../components/StationList.vue'
import UpsellBanner from '../components/UpsellBanner.vue'
const LeafletMap = defineAsyncComponent(() => import('../components/LeafletMap.vue'))
import LandingNav from '../components/landing/LandingNav.vue'
@@ -453,14 +445,15 @@ const PRICES = {
annual: { basic: '£9.90', plus: '£24.90', pro: '£39.90' },
}
const PRICE_SUFFIX = { monthly: '/mo', annual: '/yr' }
const { stations, meta, loading, error, search, reset } = useStations()
const { stations, meta, prediction, loading, error, search, reset } = useStations()
const showFullPrediction = computed(() => Boolean(prediction.value) && !prediction.value.tier_locked)
watch(loading, (isLoading) => {
if (!isLoading) return
nextTick(() => {
window.scrollBy({ top: 40, behavior: 'smooth' })
})
})
// watch(loading, (isLoading) => {
// if (!isLoading) return
// nextTick(() => {
// window.scrollBy({ top: 40, behavior: 'smooth' })
// })
// })
const searchOrigin = computed(() => {
if (meta.value?.lat != null && meta.value?.lng != null) {

View File

@@ -19,11 +19,39 @@
</div>
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p>
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Your plan</p>
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
Upgrade for alerts + predictions
</a>
<dl v-if="isPaidTier" class="grid grid-cols-1 sm:grid-cols-3 gap-4 pt-3 mt-3 border-t border-zinc-200">
<div v-if="subscribedAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Subscribed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscribedAt) }}</dd>
</div>
<div v-if="subscriptionCadence">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Billed</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5 capitalize">{{ subscriptionCadence }}</dd>
</div>
<div v-if="subscriptionExpiresAt">
<dt class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">
{{ subscriptionCancelled ? 'Ends on' : 'Renews on' }}
</dt>
<dd class="text-sm font-semibold text-zinc-800 mt-0.5">{{ formatDate(subscriptionExpiresAt) }}</dd>
</div>
</dl>
<div v-if="isPaidTier && !subscriptionCancelled" class="pt-3 mt-3 border-t border-zinc-200">
<a
class="inline-flex items-center gap-1.5 text-sm font-semibold text-mauve hover:text-zinc-800 transition-colors"
href="/billing/portal"
>
<iconify-icon class="text-base" icon="lucide:circle-x"></iconify-icon>
Cancel subscription
</a>
<p class="text-xs text-zinc-500 mt-1">
You'll keep your features until the end of the billing period.
</p>
</div>
</div>
</div>
</template>
@@ -32,7 +60,25 @@
import { RouterLink } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'
const { user, userTier } = useAuth()
const {
user,
userTier,
isPaidTier,
subscriptionCancelled,
subscriptionCadence,
subscribedAt,
subscriptionExpiresAt,
} = useAuth()
const dateFormatter = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' })
function formatDate(value) {
if (!value) {
return ''
}
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '' : dateFormatter.format(date)
}
const quickLinks = [
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark', description: 'Stations you\'ve bookmarked for quick access.' },

View File

@@ -76,7 +76,7 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest">Or enter setup key manually</p>
<p class="font-mono text-[10px] uppercase tracking-widest text-zinc-500 mb-1 truncate">Or enter setup key manually</p>
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
</div>
</div>

View File

@@ -2,7 +2,6 @@
use App\Enums\FuelType;
use App\Http\Controllers\Api\AuthController;
use App\Http\Controllers\Api\PredictionController;
use App\Http\Controllers\Api\StationController;
use App\Http\Controllers\Api\StatsController;
use App\Http\Controllers\Api\UserController;
@@ -13,6 +12,7 @@ use Illuminate\Support\Facades\Route;
// Public endpoints (no API key required)
Route::post('/auth/register', [AuthController::class, 'register']);
Route::post('/auth/login', [AuthController::class, 'login']);
Route::get('/auth/me', [AuthController::class, 'me']);
Route::get('/fuel-types', function () {
return Cache::remember('api:fuel-types', now()->addDay(), fn () => collect(FuelType::cases())
@@ -26,12 +26,10 @@ Route::get('/stats/live', [StatsController::class, 'live']);
Route::middleware(['throttle:60,1', VerifyApiKey::class])->group(function (): void {
Route::get('/stations', [StationController::class, 'index']);
Route::get('/stats/searches', [StatsController::class, 'searches']);
Route::get('/prediction', [PredictionController::class, 'index']);
});
// Sanctum-authenticated endpoints
Route::middleware('auth:sanctum')->group(function (): void {
Route::get('/auth/me', [AuthController::class, 'me']);
Route::post('/auth/logout', [AuthController::class, 'logout']);
// User dashboard endpoints

View File

@@ -33,6 +33,14 @@ Schedule::command('oil:predict --fetch')
->onOneServer()
->runInBackground();
// Move station_prices rows older than 12 months into station_prices_archive
// once a month. Keeps the partitioned hot table bounded.
Schedule::command('fuel:archive')
->monthlyOn(1, '04:00')
->withoutOverlapping()
->onOneServer()
->runInBackground();
// Scheduled WhatsApp updates — morning and evening
Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer();
Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer();

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\Plan;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -68,6 +69,175 @@ it('returns the authenticated user on /me', function () {
->assertJsonPath('email', $user->email);
});
it('does not leak sensitive or internal user fields on /me', function () {
$user = User::factory()->create([
'is_admin' => true,
'stripe_id' => 'cus_secret',
'pm_type' => 'visa',
'pm_last_four' => '4242',
'postcode' => 'SW1A 1AA',
]);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_secret',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk();
$payload = $response->json();
expect(array_keys($payload))->toEqualCanonicalizing([
'name',
'email',
'two_factor_confirmed_at',
'tier',
'subscription_cancelled',
'subscription_cadence',
'subscribed_at',
'subscription_expires_at',
]);
});
it('reports subscription_cancelled=false for a user with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=false for an active paid subscription', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false);
});
it('reports subscription_cancelled=true once the subscription is set to end at period end', function () {
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => now()->addDays(20),
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
});
it('exposes subscribed_at, cadence and renewal date for an active monthly subscription', function () {
Plan::where('name', 'plus')->update([
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
]);
$user = User::factory()->create();
$subscribedAt = now()->subDays(10)->startOfSecond();
$renewalAt = now()->addDays(20)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_monthly_active',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly_test',
'quantity' => 1,
'current_period_end' => $renewalAt,
'created_at' => $subscribedAt,
'updated_at' => $subscribedAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', 'monthly');
expect($response->json('subscribed_at'))->toStartWith($subscribedAt->toDateString());
expect($response->json('subscription_expires_at'))->toStartWith($renewalAt->toDateString());
});
it('reports cadence as annual when the active price is the annual one', function () {
Plan::where('name', 'pro')->update([
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
]);
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_annual_active',
'stripe_status' => 'active',
'stripe_price' => 'price_pro_annual_test',
'quantity' => 1,
]);
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cadence', 'annual');
});
it('uses ends_at as the expiry date when subscription is cancelled', function () {
$user = User::factory()->create();
$endsAt = now()->addDays(15)->startOfSecond();
$renewalAt = now()->addDays(30)->startOfSecond();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_cancelling_with_period',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
'ends_at' => $endsAt,
'current_period_end' => $renewalAt,
]);
$response = $this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', true);
expect($response->json('subscription_expires_at'))->toStartWith($endsAt->toDateString());
});
it('returns null subscription metadata for users with no subscription', function () {
$user = User::factory()->create();
$this->actingAs($user, 'sanctum')
->getJson('/api/auth/me')
->assertOk()
->assertJsonPath('subscription_cancelled', false)
->assertJsonPath('subscription_cadence', null)
->assertJsonPath('subscribed_at', null)
->assertJsonPath('subscription_expires_at', null);
});
it('logs out and revokes the token', function () {
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
@@ -80,6 +250,12 @@ it('logs out and revokes the token', function () {
expect($user->tokens()->count())->toBe(0);
});
it('returns 401 on protected routes without a token', function () {
$this->getJson('/api/auth/me')->assertUnauthorized();
it('returns null on /me when unauthenticated', function () {
$response = $this->getJson('/api/auth/me')->assertOk();
expect($response->getContent())->toBe('null');
});
it('returns 401 on protected routes without a token', function () {
$this->postJson('/api/auth/logout')->assertUnauthorized();
});

View File

@@ -1,70 +0,0 @@
<?php
use App\Enums\FuelType;
use App\Models\Station;
use App\Models\StationPriceCurrent;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
});
it('returns a prediction response', function () {
$this->getJson('/api/prediction')
->assertOk()
->assertJsonStructure([
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals' => [
'trend' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'day_of_week' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'brand_behaviour' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'national_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'regional_momentum' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
'price_stickiness' => ['score', 'confidence', 'direction', 'detail', 'data_points', 'enabled'],
],
])
->assertJsonPath('fuel_type', 'e10')
->assertJsonPath('region_key', 'national');
});
it('includes current average from live prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14750,
]);
$response = $this->getJson('/api/prediction')->assertOk();
expect($response->json('current_avg'))->toBe(147.5);
});
it('returns regional prediction when lat and lng are provided', function () {
$this->getJson('/api/prediction?lat=52.5&lng=-0.2')
->assertOk()
->assertJsonPath('region_key', 'regional')
->assertJsonPath('fuel_type', 'e10');
});
it('returns national prediction without coordinates', function () {
$this->getJson('/api/prediction')
->assertOk()
->assertJsonPath('region_key', 'national');
});
it('returns 422 for invalid lat', function () {
$this->getJson('/api/prediction?lat=999&lng=0')
->assertUnprocessable()
->assertJsonValidationErrors(['lat']);
});
it('returns 422 for invalid lng', function () {
$this->getJson('/api/prediction?lat=51.5&lng=999')
->assertUnprocessable()
->assertJsonValidationErrors(['lng']);
});

View File

@@ -1,8 +1,10 @@
<?php
use App\Enums\FuelType;
use App\Filament\Resources\UserResource;
use App\Models\Station;
use App\Models\StationPriceCurrent;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
@@ -12,6 +14,15 @@ beforeEach(function () {
$this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]);
});
function asPaidUserOnStations(string $tier = 'plus'): User
{
test()->artisan('db:seed', ['--class' => 'PlanSeeder']);
$user = User::factory()->create();
UserResource::applyTier($user, $tier, 'monthly');
return $user;
}
it('returns stations near coordinates filtered by fuel type', function () {
$station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]);
StationPriceCurrent::factory()->create([
@@ -192,3 +203,37 @@ it('includes resolved lat and lng in meta when postcode is provided', function (
->assertJsonPath('meta.lat', 51.5010)
->assertJsonPath('meta.lng', -0.1415);
});
it('embeds a tier-locked prediction teaser for guest requests', function () {
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'tier_locked']])
->assertJsonMissingPath('prediction.signals')
->assertJsonMissingPath('prediction.weekly_summary');
});
it('embeds a tier-locked teaser for free-tier authenticated users', function () {
asPaidUserOnStations('free');
$user = User::query()->latest('id')->first();
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonPath('prediction.tier_locked', true)
->assertJsonMissingPath('prediction.signals');
});
it('embeds the full prediction payload for plus users', function () {
$user = asPaidUserOnStations('plus');
Station::factory()->create(['lat' => 52.555, 'lng' => -0.256]);
$this->actingAs($user, 'sanctum')
->getJson('/api/stations?lat=52.555&lng=-0.256&fuel_type=e10&radius=10')
->assertOk()
->assertJsonStructure(['prediction' => ['fuel_type', 'predicted_direction', 'confidence_score', 'reasoning', 'weekly_summary', 'signals']])
->assertJsonMissingPath('prediction.tier_locked');
});

View File

@@ -0,0 +1,69 @@
<?php
use App\Models\Station;
use App\Models\StationPrice;
use App\Models\StationPriceArchive;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('moves prices older than 12 months to archive', function (): void {
$station = Station::factory()->create();
StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_effective_at' => now()->subMonths(13),
'price_reported_at' => now()->subMonths(13),
'recorded_at' => now()->subMonths(13),
]);
StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_effective_at' => now()->subMonths(6),
'price_reported_at' => now()->subMonths(6),
'recorded_at' => now()->subMonths(6),
]);
$this->artisan('fuel:archive')->assertSuccessful();
expect(StationPrice::count())->toBe(1)
->and(StationPriceArchive::count())->toBe(1);
});
it('outputs no-op message when nothing qualifies', function (): void {
$station = Station::factory()->create();
StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_effective_at' => now()->subMonths(3),
'price_reported_at' => now()->subMonths(3),
'recorded_at' => now()->subMonths(3),
]);
$this->artisan('fuel:archive')
->expectsOutputToContain('No prices to archive.')
->assertSuccessful();
expect(StationPrice::count())->toBe(1)
->and(StationPriceArchive::count())->toBe(0);
});
it('preserves the row data when archiving', function (): void {
$station = Station::factory()->create();
$original = StationPrice::factory()->create([
'station_id' => $station->node_id,
'price_pence' => 14523,
'price_effective_at' => now()->subMonths(13),
'price_reported_at' => now()->subMonths(13),
'recorded_at' => now()->subMonths(13),
]);
$this->artisan('fuel:archive')->assertSuccessful();
$archived = StationPriceArchive::first();
expect($archived)->not->toBeNull()
->and($archived->station_id)->toBe($original->station_id)
->and($archived->price_pence)->toBe(14523);
});

View File

@@ -1,33 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Map;
use Livewire\Livewire;
it('renders the map component', function () {
Livewire::test(Map::class)
->assertStatus(200);
});
it('dispatches map-update browser event when stations-found is received', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [['name' => 'BP Garage']],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 1],
radius: 5,
prediction: null
)
->assertDispatched('map-update');
});
it('passes radius in map-update payload', function () {
Livewire::test(Map::class)
->dispatch('stations-found',
results: [],
meta: ['lat' => 51.5, 'lng' => -0.1, 'count' => 0],
radius: 10,
prediction: null
)
->assertDispatched('map-update', fn ($event, $params) =>
$params['radius'] === 10
);
});

View File

@@ -1,52 +0,0 @@
<?php
use App\Livewire\Public\Fuel\Recommendation;
use Livewire\Livewire;
it('renders nothing before stations-found fires', function () {
Livewire::test(Recommendation::class)
->assertStatus(200)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('shows recommendation card when stations-found includes a prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices are rising sharply.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSet('prediction', $prediction)
->assertSee('Recommendation')
->assertSee('Fill up now');
});
it('shows nothing when stations-found has null prediction', function () {
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertSet('prediction', null)
->assertDontSee('Recommendation');
});
it('clears previous prediction when new stations-found fires with null prediction', function () {
$prediction = [
'action' => 'fill_now',
'confidence_score' => 80.0,
'confidence_label' => 'high',
'reasoning' => 'Prices rising.',
'predicted_direction' => 'up',
'predicted_change_pence' => 3.5,
];
Livewire::test(Recommendation::class)
->dispatch('stations-found', results: [], meta: [], prediction: $prediction, radius: 5)
->assertSee('Recommendation')
->dispatch('stations-found', results: [], meta: [], prediction: null, radius: 5)
->assertDontSee('Recommendation');
});

View File

@@ -1,72 +0,0 @@
<?php
use App\Livewire\Public\Fuel\StationList;
use Livewire\Livewire;
it('renders empty state before any search', function () {
Livewire::test(StationList::class)
->assertStatus(200)
->assertSet('hasSearched', false)
->assertDontSee('Stations Nearby');
});
it('shows station cards after stations-found event', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
$meta = ['count' => 1, 'lowest_pence' => 14390, 'avg_pence' => 14390.0];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: $meta, prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('Stations Nearby')
->assertSee('BP Garage')
->assertSee('1 Result');
});
it('shows empty state message when stations-found has no results', function () {
Livewire::test(StationList::class)
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertSet('hasSearched', true)
->assertSee('No stations found');
});
it('updates results when stations-found fires again', function () {
$station = [
'station_id' => 'abc123',
'name' => 'BP Garage',
'brand' => 'BP',
'is_supermarket' => false,
'address' => '1 High Street',
'postcode' => 'SW1A 1AA',
'lat' => 51.5074,
'lng' => -0.1278,
'distance_km' => 1.5,
'fuel_type' => 'e10',
'price_pence' => 14390,
'price' => 143.9,
'price_updated_at' => '2026-04-05T08:00:00.000Z',
'price_classification' => 'current',
'price_classification_label' => 'Current',
];
Livewire::test(StationList::class)
->dispatch('stations-found', results: [$station], meta: ['count' => 1], prediction: null, radius: 5)
->assertSee('BP Garage')
->dispatch('stations-found', results: [], meta: ['count' => 0], prediction: null, radius: 5)
->assertDontSee('BP Garage');
});

View File

@@ -1,9 +0,0 @@
<?php
use App\Livewire\Public\FuelFinder;
use Livewire\Livewire;
it('renders the fuel finder shell', function () {
Livewire::test(FuelFinder::class)
->assertStatus(200);
});

View File

@@ -2,6 +2,7 @@
use App\Jobs\SendPaymentFailedReminderJob;
use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -121,6 +122,63 @@ it('on invoice.payment_failed sets grace_period_until 5 days out and queues both
Queue::assertPushed(SendPaymentFailedReminderJob::class, 2);
});
it('persists current_period_start, current_period_end and stripe_data on subscription.updated', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_1']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_1',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$start = 1714377600;
$end = 1717056000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_1',
'customer' => 'cus_period_1',
'current_period_start' => $start,
'current_period_end' => $end,
'status' => 'active',
]],
]));
$sub = Subscription::where('stripe_id', 'sub_period_1')->first();
expect($sub->current_period_start->timestamp)->toBe($start);
expect($sub->current_period_end->timestamp)->toBe($end);
expect($sub->stripe_data)->toMatchArray(['id' => 'sub_period_1', 'status' => 'active']);
});
it('reads current_period_end from items.data[0] when not at the root (newer Stripe API)', function (): void {
$user = User::factory()->create(['stripe_id' => 'cus_period_2']);
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_period_2',
'stripe_status' => 'active',
'stripe_price' => 'price_plus_monthly',
'quantity' => 1,
]);
$end = 1719648000;
(new HandleStripeWebhook)->handle(new WebhookReceived([
'type' => 'customer.subscription.updated',
'data' => ['object' => [
'id' => 'sub_period_2',
'customer' => 'cus_period_2',
'items' => ['data' => [['current_period_end' => $end]]],
]],
]));
expect(Subscription::where('stripe_id', 'sub_period_2')->value('current_period_end')->timestamp)->toBe($end);
});
it('repeat invoice.payment_failed within grace does not re-dispatch reminders', function (): void {
Queue::fake();
$existingGrace = now()->addDays(3)->startOfSecond();

View File

@@ -7,7 +7,9 @@ use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Notifications\FuelPriceAlert;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
@@ -39,6 +41,39 @@ it('logs a sent entry for each allowed channel', function (): void {
->and($log->fuel_type)->toBe(FuelType::E10->value);
});
it('actually dispatches FuelPriceAlert with the allowed channels', function (): void {
Notification::fake();
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'email',
'fuel_type' => FuelType::E10->value,
'enabled' => true,
]);
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value, price: 143.9))->handle();
Notification::assertSentTo($user, FuelPriceAlert::class, function (FuelPriceAlert $n) {
return $n->triggerType === 'price_threshold'
&& $n->fuelType === FuelType::E10->value
&& $n->price === 143.9
&& in_array('email', $n->channels, true);
});
});
it('does not dispatch FuelPriceAlert when no channels are allowed', function (): void {
Notification::fake();
// Free user with no preferences — channelsFor returns []
$user = User::factory()->create();
(new DispatchUserNotificationJob($user, 'price_threshold', FuelType::E10->value))->handle();
Notification::assertNothingSent();
});
// ─── DispatchUserNotificationJob — tier_restricted logging ───────────────────
it('logs tier_restricted for channels the user wants but their tier forbids', function (): void {
@@ -69,11 +104,10 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu
$user = User::factory()->create();
// Patch the free plan to allow sms with limit 1
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 1];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'sms_enabled' => true,
'sms_daily_limit' => 1,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -106,14 +140,11 @@ it('logs daily_limit when the channel is allowed but the limit is exhausted', fu
it('does not log channels the user has explicitly disabled', function (): void {
$user = User::factory()->create();
// Patch free plan to allow sms
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['sms'] = ['enabled' => true, 'daily_limit' => 3];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'sms_enabled' => true,
'sms_daily_limit' => 3,
]);
// User has sms pref but it is disabled
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
@@ -145,12 +176,11 @@ it('dispatches DispatchUserNotificationJob for eligible whatsapp users', functio
$user = User::factory()->create();
// Patch free plan to allow whatsapp with scheduled updates
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -171,11 +201,11 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 1, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 1,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
@@ -184,7 +214,6 @@ it('skips users who have hit their whatsapp daily limit', function (): void {
'enabled' => true,
]);
// Exhaust the daily limit
NotificationLog::factory()->create([
'user_id' => $user->id,
'channel' => 'whatsapp',
@@ -204,11 +233,11 @@ it('passes scheduled_morning trigger for morning period', function (): void {
$user = User::factory()->create();
$freePlan = Plan::where('name', 'free')->first();
$features = $freePlan->features;
$features['whatsapp'] = ['enabled' => true, 'daily_limit' => 5, 'scheduled_updates' => 2];
$freePlan->features = $features;
$freePlan->save();
Plan::where('name', 'free')->first()->update([
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
]);
UserNotificationPreference::factory()->create([
'user_id' => $user->id,

View File

@@ -28,20 +28,19 @@ it('canUseChannel returns false for sms on free tier', function (): void {
it('canUseChannel returns false for sms on basic tier', function (): void {
$plan = Plan::where('name', 'basic')->first();
// basic has sms.enabled = false in features
expect($plan->features['sms']['enabled'])->toBeFalse();
expect($plan->sms_enabled)->toBeFalse();
});
it('canUseChannel returns true for sms on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
expect($plan->sms_enabled)->toBeTrue();
});
it('canUseChannel returns true for sms on pro tier', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['sms']['enabled'])->toBeTrue();
expect($plan->sms_enabled)->toBeTrue();
});
// ─── canSendNow ───────────────────────────────────────────────────────────────
@@ -54,10 +53,9 @@ it('canSendNow returns false when tier does not allow the channel', function ():
});
it('canSendNow returns false when daily limit is reached', function (): void {
$plan = Plan::where('name', 'plus')->first(); // sms daily_limit = 1
$plan = Plan::where('name', 'plus')->first(); // sms_daily_limit = 1
$user = User::factory()->create();
// Give user a preference so channelsFor works, and log one sent SMS today
UserNotificationPreference::factory()->create([
'user_id' => $user->id,
'channel' => 'sms',
@@ -72,10 +70,8 @@ it('canSendNow returns false when daily limit is reached', function (): void {
'created_at' => now(),
]);
// Manually bypass resolveForUser by using the plus plan features directly
expect($plan->features['sms']['daily_limit'])->toBe(1);
expect($plan->sms_daily_limit)->toBe(1);
// Confirm log count matches limit
$sentCount = NotificationLog::where('user_id', $user->id)
->where('channel', 'sms')
->where('sent', true)
@@ -88,7 +84,7 @@ it('canSendNow returns false when daily limit is reached', function (): void {
// ─── canTrackFuelType ─────────────────────────────────────────────────────────
it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
$plan = Plan::where('name', 'basic')->first(); // max = 1
$plan = Plan::where('name', 'basic')->first(); // max_fuel_types = 1
$user = User::factory()->create();
UserNotificationPreference::factory()->create([
@@ -98,7 +94,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
'enabled' => true,
]);
expect($plan->features['fuel_types']['max'])->toBe(1);
expect($plan->max_fuel_types)->toBe(1);
$count = UserNotificationPreference::where('user_id', $user->id)
->distinct('fuel_type')
@@ -110,7 +106,7 @@ it('canTrackFuelType respects max limit for non-pro tiers', function (): void {
it('pro tier has null fuel type limit meaning unlimited', function (): void {
$plan = Plan::where('name', 'pro')->first();
expect($plan->features['fuel_types']['max'])->toBeNull();
expect($plan->max_fuel_types)->toBeNull();
});
// ─── can() feature flags ──────────────────────────────────────────────────────
@@ -118,19 +114,18 @@ it('pro tier has null fuel type limit meaning unlimited', function (): void {
it('can returns false for ai_predictions on free tier', function (): void {
$plan = Plan::where('name', 'free')->first();
expect($plan->features['ai_predictions'])->toBeFalse();
expect($plan->ai_predictions)->toBeFalse();
});
it('can returns true for ai_predictions on plus tier', function (): void {
$plan = Plan::where('name', 'plus')->first();
expect($plan->features['ai_predictions'])->toBeTrue();
expect($plan->ai_predictions)->toBeTrue();
});
// ─── PlanSeeder idempotency ───────────────────────────────────────────────────
it('PlanSeeder is idempotent', function (): void {
// Run seeder a second time
$this->artisan('db:seed', ['--class' => 'PlanSeeder']);
expect(Plan::count())->toBe(4);
@@ -211,15 +206,15 @@ it('scopeForFuelType filters by fuel type', function (): void {
// ─── push frequency ───────────────────────────────────────────────────────────
it('seeds push.frequency for every tier', function (): void {
expect(Plan::where('name', 'free')->first()->features['push'])
->toBe(['enabled' => false, 'frequency' => 'none'])
->and(Plan::where('name', 'basic')->first()->features['push'])
->toBe(['enabled' => true, 'frequency' => 'daily'])
->and(Plan::where('name', 'plus')->first()->features['push'])
->toBe(['enabled' => true, 'frequency' => 'triggered'])
->and(Plan::where('name', 'pro')->first()->features['push'])
->toBe(['enabled' => true, 'frequency' => 'triggered']);
it('seeds push frequency for every tier', function (): void {
expect(Plan::where('name', 'free')->first())
->push_enabled->toBeFalse()->push_frequency->toBe('none')
->and(Plan::where('name', 'basic')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('daily')
->and(Plan::where('name', 'plus')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('triggered')
->and(Plan::where('name', 'pro')->first())
->push_enabled->toBeTrue()->push_frequency->toBe('triggered');
});
// ─── display name ─────────────────────────────────────────────────────────────

View File

@@ -43,12 +43,12 @@ it('saves email frequency on edit', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.email.frequency' => 'daily',
'email_frequency' => 'daily',
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['email']['frequency'])->toBe('daily');
expect($plan->fresh()->email_frequency)->toBe('daily');
});
it('saves sms daily limit on edit', function (): void {
@@ -56,12 +56,12 @@ it('saves sms daily limit on edit', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.sms.daily_limit' => 3,
'sms_daily_limit' => 3,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['sms']['daily_limit'])->toBe(3);
expect($plan->fresh()->sms_daily_limit)->toBe(3);
});
it('saves null fuel type max for pro (unlimited)', function (): void {
@@ -69,10 +69,10 @@ it('saves null fuel type max for pro (unlimited)', function (): void {
Livewire::test(EditPlan::class, ['record' => $plan->id])
->fillForm([
'features.fuel_types.max' => null,
'max_fuel_types' => null,
])
->call('save')
->assertHasNoFormErrors();
expect($plan->fresh()->features['fuel_types']['max'])->toBeNull();
expect($plan->fresh()->max_fuel_types)->toBeNull();
});

View File

@@ -1,5 +1,6 @@
<?php
use Database\Seeders\PlanSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -16,6 +17,9 @@ use Tests\TestCase;
pest()->extend(TestCase::class)
->use(RefreshDatabase::class)
->beforeEach(function (): void {
$this->seed(PlanSeeder::class);
})
->in('Feature', 'Unit');
/*

View File

@@ -3,10 +3,26 @@
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Illuminate\Support\Facades\DB;
use Laravel\Fortify\Features;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// SQLite lacks GREATEST/LEAST scalar functions — register PHP-backed
// shims so the haversine and other math expressions used in
// production-style queries run identically in :memory: tests.
// Idempotent: registering twice on the same PDO is harmless.
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);
}
}
protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void
{
if (! Features::enabled($feature)) {

View File

@@ -38,11 +38,24 @@ it('fetches and stores brent prices from EIA', function (): void {
->and(BrentPrice::find('2026-04-02')->price_usd)->toBe('73.80');
});
it('throws when EIA returns a 500', function (): void {
it('throws with HTTP status when EIA returns a 500', function (): void {
Http::fake(['*eia.gov/*' => Http::response([], 500)]);
expect(fn () => $this->fetcher->fetchFromEia())
->toThrow(BrentPriceFetchException::class, 'EIA returned HTTP 500');
});
it('retries EIA on transient 500 and succeeds', function (): void {
Http::fake([
'*eia.gov/*' => Http::sequence()
->push([], 500)
->push(['response' => ['data' => [['period' => '2026-04-01', 'value' => '75.10']]]]),
]);
$this->fetcher->fetchFromEia();
})->throws(BrentPriceFetchException::class);
expect(BrentPrice::count())->toBe(1);
});
it('throws when EIA returns empty data', function (): void {
Http::fake(['*eia.gov/*' => Http::response(['response' => ['data' => []]])]);
@@ -84,11 +97,24 @@ it('fetches and stores brent prices from FRED', function (): void {
expect(BrentPrice::count())->toBe(2);
});
it('throws when FRED fails', function (): void {
it('throws with HTTP status when FRED returns a 500', function (): void {
Http::fake(['*/fred/series/observations*' => Http::response([], 500)]);
expect(fn () => $this->fetcher->fetchFromFred())
->toThrow(BrentPriceFetchException::class, 'FRED returned HTTP 500');
});
it('retries FRED on transient 500 and succeeds', function (): void {
Http::fake([
'*/fred/series/observations*' => Http::sequence()
->push([], 500)
->push(['observations' => [['date' => '2026-04-01', 'value' => '75.10']]]),
]);
$this->fetcher->fetchFromFred();
})->throws(BrentPriceFetchException::class);
expect(BrentPrice::count())->toBe(1);
});
it('filters out FRED missing value markers', function (): void {
Http::fake([

View File

@@ -61,7 +61,7 @@ it('returns null when fewer than 14 prices are available for EWMA', function ():
expect($this->predictor->generateEwmaPrediction($prices))->toBeNull();
});
it('stores both EWMA and LLM predictions when provider succeeds', function (): void {
it('stores only the LLM prediction when the provider succeeds', function (): void {
seedPrices(20);
$this->provider->shouldReceive('predict')->once()->andReturn(new PricePrediction([
@@ -76,7 +76,8 @@ it('stores both EWMA and LLM predictions when provider succeeds', function (): v
$prediction = $this->predictor->generatePrediction();
expect($prediction->source)->toBe(PredictionSource::LlmWithContext)
->and(PricePrediction::count())->toBe(2);
->and(PricePrediction::count())->toBe(1)
->and(PricePrediction::where('source', PredictionSource::Ewma)->count())->toBe(0);
});
it('falls back to EWMA when provider returns null', function (): void {

View File

@@ -6,6 +6,7 @@ use App\Models\StationPrice;
use App\Models\StationPriceCurrent;
use App\Services\NationalFuelPredictionService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
@@ -78,14 +79,96 @@ it('includes all required keys in response', function () {
'fuel_type', 'current_avg', 'predicted_direction', 'predicted_change_pence',
'confidence_score', 'confidence_label', 'action', 'reasoning',
'prediction_horizon_days', 'region_key', 'methodology',
'signals',
'weekly_summary', 'signals',
])
->and($result['signals'])->toHaveKeys([
'trend', 'day_of_week', 'brand_behaviour',
'national_momentum', 'regional_momentum', 'price_stickiness',
'national_momentum', 'regional_momentum', 'price_stickiness', 'oil',
])
->and($result['weekly_summary'])->toHaveKeys([
'yesterday_avg', 'today_avg', 'tomorrow_estimated_avg',
'yesterday_today_delta_pence', 'last_7_days_series',
'last_7_days_change_pence', 'cheapest_day', 'priciest_day', 'is_regional',
]);
});
it('weekly_summary returns null prices and empty series when there is no data', function () {
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBeNull()
->and($weekly['yesterday_today_delta_pence'])->toBeNull()
->and($weekly['last_7_days_series'])->toBe([])
->and($weekly['cheapest_day'])->toBeNull()
->and($weekly['priciest_day'])->toBeNull()
->and($weekly['is_regional'])->toBeFalse();
});
it('weekly_summary populates yesterday avg, today avg and 7-day series from station_prices', function () {
$station = Station::factory()->create();
StationPriceCurrent::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo * 50),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
$weekly = $result['weekly_summary'];
expect($weekly['yesterday_avg'])->toBe(140.5)
->and($weekly['today_avg'])->toBe(140.0)
->and($weekly['yesterday_today_delta_pence'])->toBe(-0.5)
->and(count($weekly['last_7_days_series']))->toBe(7)
->and($weekly['cheapest_day']['avg'])->toBe(140.0)
->and($weekly['priciest_day']['avg'])->toBe(143.0);
});
it('weekly_summary falls back from regional to national when regional data is empty', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Coordinates 600+ km away from any station — no regional data available.
$result = app(NationalFuelPredictionService::class)->predict(58.0, -3.0);
$weekly = $result['weekly_summary'];
expect($weekly['is_regional'])->toBeFalse()
->and(count($weekly['last_7_days_series']))->toBe(7);
});
it('weekly_summary marks is_regional true when stations exist within 50km of coordinates', function () {
$station = Station::factory()->create(['lat' => 51.5, 'lng' => -0.1]);
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict(51.5074, -0.1278);
expect($result['weekly_summary']['is_regional'])->toBeTrue();
});
it('always returns e10 as fuel_type', function () {
$result = app(NationalFuelPredictionService::class)->predict();
@@ -146,3 +229,166 @@ it('disables trend signal when r_squared is below 0.5', function () {
// Trend signal may be disabled if both 5-day and 14-day lookbacks fail R² threshold
expect($result['signals']['trend']['data_points'])->toBeInt();
});
it('oil signal is disabled when no price_predictions row covers today or later', function () {
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['oil']['enabled'])->toBeFalse();
});
it('oil signal picks up an llm prediction over an ewma one for the same date', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'flat',
'confidence' => 60,
'reasoning' => null,
'generated_at' => now()->subHour(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 75,
'reasoning' => 'OPEC cut',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['enabled'])->toBeTrue()
->and($oil['direction'])->toBe('up')
->and($oil['score'])->toBe(1.0)
->and($oil['confidence'])->toBe(0.75);
});
it('oil signal prefers llm_with_context over plain llm', function () {
DB::table('price_predictions')->insert([
[
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 70,
'reasoning' => 'baseline',
'generated_at' => now(),
],
[
'predicted_for' => now()->toDateString(),
'source' => 'llm_with_context',
'direction' => 'rising',
'confidence' => 82,
'reasoning' => 'with context',
'generated_at' => now(),
],
]);
$oil = app(NationalFuelPredictionService::class)->predict()['signals']['oil'];
expect($oil['direction'])->toBe('up')
->and($oil['confidence'])->toBe(0.82);
});
it('confidence reaches "high" when trend and oil agree strongly', function () {
$station = Station::factory()->create();
// Strong falling trend over 7 days, ~1p/day
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'falling',
'confidence' => 80,
'reasoning' => 'agree',
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['predicted_direction'])->toBe('down')
->and($result['confidence_score'])->toBeGreaterThanOrEqual(70)
->and($result['confidence_label'])->toBe('high');
});
it('confidence drops when trend and oil disagree', function () {
$station = Station::factory()->create();
// Strong falling trend
for ($daysAgo = 6; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 15000 - ((6 - $daysAgo) * 100),
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
// Oil disagrees: rising
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'llm',
'direction' => 'rising',
'confidence' => 80,
'reasoning' => 'opec',
'generated_at' => now(),
]);
$agree = app(NationalFuelPredictionService::class)->predict();
// Replace oil with one that agrees instead — confidence should be higher
DB::table('price_predictions')->update([
'direction' => 'falling',
]);
$disagreeReplaced = app(NationalFuelPredictionService::class)->predict();
expect($agree['confidence_score'])->toBeLessThan($disagreeReplaced['confidence_score']);
});
it('day-of-week signal activates at 21 days of history (no longer 56)', function () {
$station = Station::factory()->create();
for ($daysAgo = 25; $daysAgo >= 0; $daysAgo--) {
StationPrice::factory()->create([
'station_id' => $station->node_id,
'fuel_type' => FuelType::E10,
'price_pence' => 14000 + ($daysAgo % 7) * 50,
'price_effective_at' => now()->subDays($daysAgo)->setTime(12, 0),
]);
}
$result = app(NationalFuelPredictionService::class)->predict();
expect($result['signals']['day_of_week']['enabled'])->toBeTrue();
});
it('reasoning fallback for the wait action does not say "fill up"', function () {
// No data → trend disabled, brand disabled, oil disabled.
// Force a "down" direction by injecting an oil prediction that points down with low confidence.
DB::table('price_predictions')->insert([
'predicted_for' => now()->toDateString(),
'source' => 'ewma',
'direction' => 'falling',
'confidence' => 50,
'reasoning' => null,
'generated_at' => now(),
]);
$result = app(NationalFuelPredictionService::class)->predict();
if ($result['action'] === 'wait') {
expect($result['reasoning'])->not->toContain('fill up at the cheapest');
} else {
// If thresholds keep this at no_signal, still verify action-aware fallback exists
expect($result['reasoning'])->toBeString();
}
});

54
todo.md Normal file
View File

@@ -0,0 +1,54 @@
# Todo
Working checklist. Add sections per area. Tick boxes as you go.
---
## Stripe
Spec: `docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
Rules: `.claude/rules/payments.md`
### Pre-production (test mode first, then repeat in live mode)
- [ ] **Stripe Dashboard — retry schedule.** Billing → Automations → Subscription retry rules. Switch Smart Retries → Custom. Retry on days 1, 3, 5. After final retry → *Cancel subscription*.
- [ ] **Stripe Dashboard — customer emails.** Emails → Customer emails. Enable "Successful payments", "Failed payments", "Upcoming renewals".
- [ ] **Stripe Dashboard — branding.** Settings → Branding. Upload FuelAlert logo, set primary colour to match app accent.
- [ ] **Stripe Dashboard — Customer Portal.** Settings → Billing → Customer Portal. Allow plan changes across all 6 prices (basic/plus/pro × monthly/annual), cancellation at period end only, card updates, invoice history. Hide everything else.
- [ ] **Stripe Dashboard — webhook endpoint.** Developers → Webhooks. Add endpoint at `{APP_URL}/stripe/webhook`. Subscribe to: `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_succeeded`, `invoice.payment_failed`. Copy signing secret → production `.env` as `STRIPE_WEBHOOK_SECRET`.
### Production env + server
- [ ] **`.env` keys** set on production:
- `STRIPE_KEY=pk_live_...`
- `STRIPE_SECRET=sk_live_...`
- `STRIPE_WEBHOOK_SECRET=whsec_...`
- `CASHIER_CURRENCY=gbp`
- `QUEUE_CONNECTION=redis`
- `STRIPE_PRICE_BASIC_MONTHLY`, `STRIPE_PRICE_BASIC_ANNUAL`
- `STRIPE_PRICE_PLUS_MONTHLY`, `STRIPE_PRICE_PLUS_ANNUAL`
- `STRIPE_PRICE_PRO_MONTHLY`, `STRIPE_PRICE_PRO_ANNUAL`
- [ ] **Run `php artisan migrate`** — adds `users.grace_period_until`.
- [ ] **Queue worker** consuming both queues: `--queue=notifications,default` (reminders go on the `notifications` queue).
- [ ] **Redis persistence** (AOF or RDB) enabled — delayed jobs sit for 35 days.
- [ ] `php artisan route:list --name=billing` — confirm 4 routes (checkout, portal, success, cancel).
### E2E QA (Stripe test mode)
Requires the Dashboard + env tasks above done first. Stripe test cards:
- `4242 4242 4242 4242` — success
- `4000 0000 0000 0341` — renewal fails (use to test dunning)
- [ ] Sign up on each paid tier × both cadences (6 combos) → confirm tier shows.
- [ ] Upgrade basic → pro via Portal → confirm instant swap.
- [ ] Downgrade pro → basic via Portal → confirm change scheduled for period end.
- [ ] Cancel mid-period → features persist until period end → drop to free.
- [ ] Use `4000 0000 0000 0341` + `stripe trigger invoice.payment_failed`:
- Banner appears on dashboard with correct "by {date}" string.
- Day-3 job is queued (visible via `php artisan queue:listen notifications`).
- Day-5 job is queued.
- Final Stripe retry fails → `customer.subscription.deleted` → user drops to free, WhatsApp + SMS prefs disabled, banner disappears.
- [ ] Recover mid-grace (update card via Portal) → `invoice.payment_succeeded` clears grace, banner disappears, queued reminders silently no-op when they run.
---