Compare commits

...

78 Commits

Author SHA1 Message Date
Ovidiu U
ecd45588e9 Add legal policy pages and shared layout component
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run
- Add Cookie Policy view documenting essential cookies (session, CSRF, remember_me, fa_location) and cookieless Umami analytics
- Add Privacy Policy view covering UK GDPR compliance, data categories, lawful bases, processors, retention, and user rights
- Add Refund & Cancellation Policy view explaining 14-day cooling-off period under Consumer Contracts Regulations 2013 and express-consent flow
- Add Terms of Service view defining account rules, subscription billing, and governing law
- Create shared legal layout component with FuelAlert header, footer with cross-links, and consistent typography
- Add feature tests covering all four legal pages and their cross-links
- All policies include placeholders for ICO registration number, email, and hosting/email providers pending production config
2026-05-14 17:43:53 +01:00
Ovidiu U
598ef04645 Rebrand from "Fuel Price" to "Fuel Alert" and update project metadata
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
- Rename project in package.json, composer.json, and .env.example
- Update app name, URLs, and session domains to fuel-alert
- Comment out testimonials section in Home.vue
- Revise footer copy: remove "
2026-05-14 14:30:02 +01:00
Ovidiu U
07e0789044 fix(forecasting): persist LLM overlay under Tier-1 ITPM via two-call architecture
The daily forecast:llm-overlay command was being skipped because the previous
single-conversation flow consumed more than Tier-1's 50,000 input-tokens-per-
minute Anthropic bucket. The web_search tool auto-caches its results (~55k
tokens) and requires `encrypted_content` intact when those blocks are resent,
so the prior retry-on-missing-citations path either 429'd or 400'd on the
second call.

LlmOverlayService now runs two independent API calls. Phase 1 invokes the
web_search tool and we discard the transcript after harvesting the URLs +
titles from the returned web_search_tool_result blocks. Phase 2 is a fresh
conversation containing the forecast context and the harvested headlines as
plain text, with a forced submit_overlay tool call. events_cited is now
optional in the tool schema — Haiku's flaky compliance no longer matters
because citations come from the search results, not the model's transcription.
Model-tagged events (with directional impact) merge with harvested-only
entries (impact: 'neutral'), deduped by URL.

Between phases the service reads anthropic-ratelimit-input-tokens-remaining /
…-reset from Phase 1's headers and sleeps proportionally — only long enough
for the SUBMIT_TOKEN_BUDGET worth of refill, not for the full bucket reset,
capped at 65 seconds.

ApiLogger now captures usage.input_tokens, usage.output_tokens,
cache_read_input_tokens, cache_creation_input_tokens, plus the rate-limit
remaining/reset headers on every Anthropic response. New nullable columns on
api_logs make rate-limit diagnostics directly queryable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:22:42 +01:00
Ovidiu U
97e27fc057 feat(ui): mobile-first redesign — compact hero, inline submit button, map-first with collapsible list
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
- Hero: remove full-width mobile submit, add inline "Go" button next to locate
- Prediction cards: tighter mobile padding (px-3 py-3)
- Search filters: right-aligned toolbar, remove "X stations found" count and map toggle
- Map: initialize view immediately to avoid tile wiggle, skip recenter on fresh init
- Station list: hidden by default, toggled via "Stations {count}" pill above map
- Typography: hide desktop h1 on mobile, scale down section headings and spacing
- Footer: remove uppercase styling from headings and copyright line
- Filter popover: auto-close on fuel/radius/sort/brand selection

fix(llm): retry submit_overlay when events_cited is missing, extend Fuel Finder timeout with retries

- LlmOverlayService: add `minItems: 1` to events_cited schema, detect missing citations
  in submit response, inject tool_result error and retry once with explicit prompt
- Log full raw_result context when no verified citations, capturing direction/confidence/reasoning
- FuelPriceService: add 3×1s retry with 60s timeout to batch price requests (was 30s no retry)
- Tests: cover successful retry recovery and rejection when retry also omits citations
2026-05-14 13:23:52 +01:00
Ovidiu U
11a3b433ff feat(ui): consolidate map filters and rework station selection
- replace inline filter pills with a single "Filters" popover containing
  small pill buttons for fuel/radius/sort/brand (no native <select>s)
- map polish: Carto Positron tiles, hidden zoom buttons, locate-me floating
  button + accuracy ring, smooth flyTo transitions, slim ⓘ attribution
- map markers no longer open Leaflet popups; clicking a marker selects the
  station and surfaces the existing StationCard inline over the map, with
  swipe-down-to-close and a small overlay × button
- price colour now reflects deal quality (cheap / average / expensive vs
  search avg ± 3p) on both list and map — stable across sort/filter
- promote the "X ago" timestamp into the card header so it stays visible
  in the expanded state

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 12:16:13 +01:00
Ovidiu U
8dad223d06 feat(admin): add Filament resources for the forecasting stack
Adds three resources under a new "Forecasting" navigation group: a full-CRUD
WatchedEventResource for the Layer 5 volatility detector, plus read-only
WeeklyForecastResource and BacktestResource so the ridge model output and
its calibration can be inspected without SQL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 09:13:05 +01:00
Ovidiu U
1c46667f56 feat(api-logs): persist response body on failed external calls
ApiLogger now stores the upstream response body to
`api_logs.response_body` whenever the call failed (non-2xx response or
a RequestException carrying a response). Successful 2xx responses
remain null so the table stays small on busy services like fuel:poll
and oil:fetch.

Truncated at 64 KB. The column is mediumText so a future cap raise
needs no schema change.

Captures:
- 4xx and 5xx response bodies verbatim
- Body extracted from RequestException via `$e->response->body()`
  when callers use `Http::throw()`

Does not capture:
- ConnectionException (no response existed)
- Generic Throwable from the closure (same reason)

Motivation: the LLM overlay's "skipped — no verified citations" path
left no forensic trail to debug. With this, the next time anything
routed through ApiLogger fails — Anthropic 429s, FRED 5xx, Fuel
Finder errors — the failed body is queryable directly:

    SELECT response_body FROM api_logs
    WHERE service = ? AND status_code >= 400
    ORDER BY id DESC LIMIT 1;

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:51:18 +01:00
Ovidiu U
203200acb9 chore: retire legacy oil prediction pipeline
Removes everything that was made redundant by the new forecasting
stack. Per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md,
this was the cleanup planned at the end of Phase 4.

Deleted services and code:
- App\Services\Prediction\Signals\* (the old six-signal aggregator —
  trend, supermarket, day-of-week, brand-behaviour, stickiness,
  regional-momentum, oil — replaced by RidgeRegressionModel).
- App\Services\NationalFuelPredictionService (the post-Phase-4 thin
  shim; StationSearchService now depends on WeeklyForecastService
  directly, set up in the previous commit).
- App\Services\LlmPrediction\* (AbstractLlmPredictionProvider plus
  the four provider implementations — Anthropic, OpenAI, Gemini, and
  the OilPredictionProvider router. Replaced by LlmOverlayService).
- App\Services\BrentPricePredictor and App\Services\Ewma. The Ewma
  helper had no callers left after BrentPricePredictor went.
- App\Models\PricePrediction and its factory.
- App\Console\Commands\PredictOilPrices (the oil:predict command).
- App\Filament\Resources\OilPredictionResource and its Pages.

Schema and dashboard:
- Drop the price_predictions table via a new migration.
- Repoint the Filament StatsOverviewWidget tile from PricePrediction
  to WeeklyForecast so the dashboard reflects the new pipeline.
- Remove the OilPredictionProvider binding from AppServiceProvider.

Test cleanup:
- Delete tests for every retired service.
- Update StatsOverviewWidgetTest to seed weekly_forecasts instead of
  price_predictions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:28 +01:00
Ovidiu U
ddd591ad47 feat(forecasting): build calibrated weekly forecast stack with LLM overlay and volatility detector
Replaces the implementation behind NationalFuelPredictionService — the
public JSON contract on /api/stations is preserved, but the engine is
new and honest.

Layers (per docs/superpowers/specs/2026-05-01-prediction-rebuild-design.md):
1. Layer 1 — WeeklyForecastService: ridge regression on 8 features
   trained on 8 years of BEIS weekly UK pump prices, confidence drawn
   from a backtested calibration table, not made up.
2. Layer 2 — LocalSnapshotService: descriptive SQL aggregates over
   station_prices_current. Never speaks about the future.
3. Layer 3 — verdict via rule gates, not confidence multipliers. The
   ridge_confidence is displayed verbatim; LLM and volatility surface
   as badges, never blended into the number.
4. Layer 4 — LlmOverlayService: daily Anthropic web-search call,
   structured submit_overlay tool, hard cap at 75% confidence,
   URL-verified citations or rejection.
5. Layer 5 — VolatilityRegimeService: hourly cron, sole owner of the
   active flag, OR-combined triggers (Brent move >3%, LLM major
   impact, station churn (gated), watched_events).

Pure-PHP linear algebra (Gauss–Jordan with partial pivoting) on the
8x8 normal-equation matrix. No external ML dependency. Backtest
harness with structural leak detection (per-feature source-timestamp
check vs target Monday) seeds the calibration table.

Backtest gate (62–68% directional accuracy on the 130-week hold-out)
ships at 61.98% with MAE 0.48 p/L — beats the naive zero-change
baseline by ~30pp on real data.

New tables: backtests, weekly_forecasts, forecast_outcomes,
llm_overlays, volatility_regimes, watched_events.

New commands: forecast:resolve-outcomes, forecast:llm-overlay,
forecast:evaluate-volatility, oil:backfill, beis:import.

Cron: oil:fetch 06:30 UK, forecast:llm-overlay 07:00 UK,
forecast:evaluate-volatility hourly, beis:import Mon 09:30,
forecast:resolve-outcomes Mon 10:00.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:40:05 +01:00
Ovidiu U
d13a29df01 docs: amend prediction rebuild spec with implementation defaults and changelog v3
Adds two sections to the spec:
- Implementation defaults: pins the four open decisions settled before
  Phase 1 (naive baseline = zero-change, math = inline pure PHP,
  coefficients on the backtests row, BEIS retrain = manual CSV + cron)
  plus the namespace, scaler, and Pest conventions.
- Changelog v3: records the verdict-via-rule-gates architecture (gates
  not multipliers), removal of weeks_since_duty_change as a feature,
  lower 62% backtest gate, structural leak detector promoted to primary.

Captured here so a future session can resume implementation without
re-deriving them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:26 +01:00
Ovidiu U
c2c237a1b3 chore: stop tracking .DS_Store
Add .DS_Store to .gitignore and untrack the two committed copies
(.DS_Store at the root and .claude/.DS_Store). macOS noise that
shouldn't have been versioned in the first place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 08:39:16 +01:00
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
Ovidiu U
3224b186b2 Merge branch 'feat/stripe-lifecycle'
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
Stripe subscription lifecycle implementation:
- Consolidated HandleStripeWebhook listener (5 event types)
- users.grace_period_until column for past-due state
- Branded Day-3 / Day-5 payment-failure reminder mailables
- SendPaymentFailedReminderJob with grace-guard self-cancel
- Past-due dashboard banner
- Deletion of old DowngradeUserOnSubscriptionDeleted listener

Spec: docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md
Plan: docs/superpowers/plans/2026-04-23-stripe-subscription-lifecycle.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 12:00:19 +01:00
Ovidiu U
36444cde05 feat: add past-due payment banner to dashboard
Show an amber banner to logged-in users whose grace_period_until is set,
linking to the Stripe Customer Portal to update their card.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:59:51 +01:00
Ovidiu U
b7175169f0 feat: handle invoice.payment_failed — set grace period and queue reminders
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:56:26 +01:00
Ovidiu U
5b17f4cae4 feat: add SendPaymentFailedReminderJob with grace guard 2026-04-23 10:51:13 +01:00
Ovidiu U
c127cc379e feat: add day-5 branded payment-failure reminder mailable 2026-04-23 10:48:22 +01:00
Ovidiu U
de2499636f feat: add day-3 branded payment-failure reminder mailable 2026-04-23 10:44:37 +01:00
Ovidiu U
2078c4b83e feat: clear grace period on invoice.payment_succeeded
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 10:37:50 +01:00
Ovidiu U
b9d457578c feat: fold subscription deletion handling into HandleStripeWebhook 2026-04-23 10:34:05 +01:00
Ovidiu U
25b79f095b feat: bust plan cache on customer.subscription.updated 2026-04-23 10:31:07 +01:00
Ovidiu U
a39d4b1b94 feat: consolidate stripe webhook handling into HandleStripeWebhook listener 2026-04-23 10:27:23 +01:00
Ovidiu U
f1c1a1c572 feat: add grace_period_until to users table 2026-04-23 10:23:27 +01:00
Ovidiu U
bf013926c0 docs: add stripe subscription lifecycle spec + implementation plan
Captures the agreed design for Stripe webhook handling, 5-day grace
period with branded day-3/day-5 reminders, and Stripe Customer Portal
as the single subscription-management surface. Updates payments rules
to match and ignores .worktrees/ for isolated implementation work.
2026-04-23 10:05:50 +01:00
Ovidiu U
19fc61a0a3 feat: accept ArcGIS ONSPD column aliases (PCD7/PCD8/PCDS) in postcodes:import 2026-04-22 13:31:27 +01:00
Ovidiu U
13fc227619 docs: link ONSPD attribution to source dataset page 2026-04-22 13:28:39 +01:00
Ovidiu U
d8f87f964d Merge branch 'feat/self-hosted-postcodes'
Self-hosted UK postcode lookup: ONS Postcode Directory loaded into
local postcodes/outcodes tables; postcodes.io retained as fallback
for place names and unknown postcodes, with successful fallback
results persisted back to the local tables.
2026-04-22 13:19:39 +01:00
Ovidiu U
975a1522cf docs: plan for self-hosted UK postcodes 2026-04-22 13:19:33 +01:00
Ovidiu U
29ba2f3d86 docs: add ONS/Royal Mail/OS attribution required by OGL v3 2026-04-22 12:39:11 +01:00
Ovidiu U
3ec7cda790 feat: derive outcode centroids from postcodes during import 2026-04-22 12:36:39 +01:00
Ovidiu U
d01a634f0b test: cover terminated + blank-coord skip paths for postcodes:import 2026-04-22 12:34:19 +01:00
Ovidiu U
9ad62538b9 fix: harden postcodes:import against duplicate headers and test collisions 2026-04-22 12:33:10 +01:00
Ovidiu U
4a60298606 feat: add postcodes:import command for loading ONSPD CSV 2026-04-22 12:28:08 +01:00
Ovidiu U
5426722c71 refactor: scope postcode cache to place names, DB is authoritative for postcodes 2026-04-22 12:23:50 +01:00
Ovidiu U
d460de1850 fix: guard malformed postcodes.io responses and isolate persist errors from HTTP success 2026-04-22 12:22:15 +01:00
Ovidiu U
45bf1c0d24 feat: persist postcodes.io fallback results into local DB 2026-04-22 12:18:20 +01:00
Ovidiu U
1e3b246172 feat: resolve outcodes from local DB before HTTP 2026-04-22 12:13:52 +01:00
Ovidiu U
9fa9ea7835 feat: resolve full postcodes from local DB before HTTP 2026-04-22 12:09:19 +01:00
Ovidiu U
55c81fab7b style: align Postcode/Outcode models with house Fillable+casts convention 2026-04-22 12:07:23 +01:00
Ovidiu U
64a7cc3de5 feat: add Postcode and Outcode Eloquent models 2026-04-22 12:04:39 +01:00
Ovidiu U
7c114c72e4 style: add void return type to postcodes migration closures 2026-04-22 12:03:51 +01:00
Ovidiu U
2fe9c3ef77 feat: add postcodes and outcodes tables for self-hosted lookup 2026-04-22 12:00:53 +01:00
Ovidiu U
b4bd78ab4c Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
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
- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count
- Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar
- Add brand filter dropdown with dynamic brand list from parent, emit update events
- Move station count from StationList to PostSearchFilters, display as "X station(s) found"
- Remove sort tabs and brand filter from StationList component
- Add force-new-line div for mobile layout between Refine and Sort groups
- Include brand filter in hasActive check and resetFilters function
- Update Home.vue to pass brands/brandFilter props and handle brandFilter updates
- Add reset() method to useStations composable to clear state on empty query
- Clear search state when route query is empty instead of attempting search
- Update Fuel Finder API base URL to include /api/v1 path
- Adjust map zoom levels for 10-15 mile radius range
- Update API token request to use retry and increase timeout to 60s
2026-04-22 11:50:59 +01:00
Ovidiu U
8335f49fd6 Redesign station cards with compact layout, improved typography, and expandable details
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
- Reduce header size and weight, convert all-caps brand names to title case
- Replace address line with distance-only in collapsed state, move brand label to expanded section
- Apply monospace font to pricing, reduce size and weight across labels
- Move badge list and full details into expandable section
- Normalize font weights throughout (semibold for headings, medium for labels)
- Create `.pill` component class with `.is-active` state for consistent filter styling
- Apply pill styling to SearchBar filters, StationList sort buttons, and brand filter
- Add `name` attributes to fuel type and radius selects
- Update package dependencies (@tailwindcss/node, @tailwindcss/oxide, @rolldown/*)
2026-04-22 11:23:05 +01:00
Ovidiu U
dd9bd95657 Redesign search UI with unified input, expandable filters, and integrated map controls
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 HeroSearch into single responsive form with inline geolocation button and submit actions
- Transform SearchBar into pill-based filter bar with visual state indicators (active filters highlighted)
- Move map toggle from separate component into SearchBar with open/close state management
- Redesign StationList sort controls as pills with icons, move brand filter inline, add result count
- Expand LeafletMap to full-width panel (96 viewport height) controlled by parent open state
- Remove nested mobile/desktop layouts in HeroSearch in favor of single adaptive form
- Add "Refine" and "Sort" labels to filter groups, implement clear-all filters button
- Show verdict card only before first search on mobile, hide after results load
- Position StatsRow within hero gradient, move results section into same gradient container
- Update map initialization to only occur when panel is open, destroy on close
- Add accessibility labels (aria-expanded, aria-controls) to map toggle button
2026-04-22 09:38:23 +01:00
Ovidiu U
afe459f248 Lazy-load dashboard and settings views to reduce initial bundle size
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
Convert dashboard and settings component imports from static to dynamic imports using arrow functions, improving initial load performance by code-splitting these routes.
2026-04-20 20:38:04 +01:00
Ovidiu U
d822b77fb0 feat: redesign homepage with responsive hero, verdict card preview, and modular landing components
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
- Extract LandingNav, LiveTicker, StatsRow, VerdictCard, and HeroSearch into reusable landing components
- Implement responsive two-layout strategy: mobile stacked (hero search + verdict card + CTA) vs desktop inline pill input with verdict card sidebar
- Add serif/mono font tokens and live-dot pulse animation to CSS
- Move verdict card above search input on mobile, to right sidebar on desktop
- Replace hero "fill up now" mockup with dynamic VerdictCard showing top stations, pricing, and recommendation
- Simplify navigation with uppercase tracking, add Fleet anchor, and gate CTA by auth state
- Lazy-load LeafletMap with defineAsyncComponent to reduce initial bundle
- Relocate SearchBar below hero on search attempt for persistent filter UI
- Add meta description for SEO
2026-04-20 20:27:02 +01:00
Ovidiu U
831637380c feat: expand station cards with detailed information and add live statistics endpoint
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 `/stats/live` endpoint returning station count and latest price timestamp with 5-minute cache
- Transform StationCard into expandable component with click/keyboard interaction showing full details
- Display brand label, badges (24h/Supermarket/Motorway), fuel types, amenities, opening hours, and price delta vs average
- Add brand filter dropdown to StationList with dynamic brand extraction from results
- Calculate and display price comparison against filtered stations average
- Redesign map markers to simpler price display; move directions link to popup alongside station details
- Add "locate-me" button to SearchBar for geolocation trigger
- Show "Live" indicator with station count and last-update time on homepage hero
- Remove standalone directions link from marker HTML; consolidate in popup with click propagation handling
- Persist `avgPence` calculation across StationList and pass to cards for delta display
- Add `@iconify-json/lucide` dev dependency and register collection on app mount
- Stop click propagation on card action buttons (directions, remove)
2026-04-20 18:58:13 +01:00
Ovidiu U
c2466e5a61 feat(tiers): add display-name layer, push.frequency entitlement, and rename pricing cards
Reconciles tier docs with `PlanSeeder` reality (basic has price_threshold
and score_alerts; schema is stripe_price_id_monthly + stripe_price_id_annual)
and introduces the display-name layer from pricing-plan.md v2.

- PlanTier::label() + Plan::displayName() + PlanFeatures::displayName()
  expose user-facing names (Free/Daily/Smart/Pro); backend identifiers stay
  basic/plus/pro so every call site, Stripe mapping, and test keeps working.
- push.frequency key added to features JSON (none/daily/triggered), mirroring
  email.frequency so Daily's daily push is distinguishable from Smart/Pro's
  triggered push. Seeder, factory, free-tier stubs, and Filament form updated.
- Homepage pricing cards renamed: Basic→Daily, Plus→Smart; badge
  "Most Popular"→"Most pick this"; CTAs refreshed.
- docs/tiers.md change log records the full diff.

Fleet tier, 14-day trial copy, and Smart dark-card treatment deferred.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 18:57:24 +01:00
Ovidiu U
7dc41ba9ee feat: add location-based search, redesign station cards, and implement URL state management
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
- Support geolocation search (lat/lng) as alternative to postcode with automatic fallback
- Redesign StationCard with expanded layout showing address, distance in miles, reliability status, directions link, and optional remove button
- Add directions integration with Google Maps including origin parameter support
- Persist search parameters (postcode/coords, fuel type, radius, sort) in URL query and hydrate on mount
- Implement compact map markers with inline directions link and click-to-zoom behavior
- Auto-trigger search when filters change (fuel type, radius, sort) if search already performed
- Add removable prop to StationCard for watchlist integration
- Display reliability status (Current/Stale/Outdated) with color-coded pricing
- Remove 2-mile radius option from search filters
2026-04-20 15:51:02 +01:00
Ovidiu U
d29f3e6487 Add tier feature design spec, annual billing, fuel type normalization, and admin subscription management
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 comprehensive tier feature matrix spec defining Free/Basic/Plus/Pro capabilities across recommendations, predictions, history, logs, tools, and family sharing
- Add `stripe_price_id_annual` column to plans table and rename existing column to `stripe_price_id_monthly`
- Normalize legacy fuel type aliases (petrol→e10, diesel→b7_standard) in users table
- Implement BillingController with checkout, portal, success/cancel routes supporting monthly/annual cadence
- Add admin subscription assignment in Filament user edit page with admin-granted subscription support
- Add DowngradeUserOnSubscriptionDeleted listener to disable WhatsApp/SMS preferences on subscription cancellation
- Add MissedNotificationsOverview widget to Filament user detail page
- Add PollFuelPricesTest covering auto-refresh scenarios
- Add PriceReliability enum with reliability classification based on price age
- Add fuelTypes.js constants file exporting FUEL_TYPES from window global
2026-04-20 14:13:03 +01:00
Ovidiu U
5acb99c9e3 Remove obsolete Livewire fuel search components and consolidate pricing tiers
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
- Delete unused Livewire Search test and fuel type select Blade component
- Move subscription webhook listener from EventServiceProvider to AppServiceProvider
- Add FUEL_TYPES global config to app layout for client-side use
- Add Billable trait to User model and include email_verified_at in fillable
- Implement monthly/annual cadence toggle with pricing display and smart CTA routing on homepage
- Update VerifyApiKeyMiddlewareTest to use e10 instead of petrol
- Refactor PollFuelPrices to auto-refresh stale stations based on last_seen_at
- Add incremental polling with cached timestamp and effective-start-timestamp param to FuelPriceService
- Normalize amenities/fuel_types from API objects to flat arrays, skip stations missing required fields
- Log response body on API failures in ApiLogger
- Default homepage sort to 'reliable' instead of 'price'
2026-04-20 14:12:15 +01:00
274 changed files with 18745 additions and 4210 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
.claude/.DS_Store vendored

Binary file not shown.

View File

@@ -5,7 +5,7 @@
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/` - Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
- Returns: all UK station prices + station metadata (~14,500 stations) - Returns: all UK station prices + station metadata (~14,500 stations)
- Update frequency: stations report within 30 minutes of price change - Update frequency: stations report within 30 minutes of price change
- Our polling interval: every 15 minutes via scheduler (incremental), full refresh once daily - Our polling interval: every 30 minutes via scheduler (incremental using `effective-start-timestamp`), station metadata auto-refreshed once per day on the first poll after midnight
### Authentication ### Authentication
@@ -35,24 +35,34 @@ Content-Type: application/json
- Include token in every API request: `Authorization: Bearer {token}` - Include token in every API request: `Authorization: Bearer {token}`
#### Endpoints #### Endpoints
- `GET /api/v1/pfs/fuel-prices?batch-number` — all/incremental station prices - `GET /api/v1/pfs/fuel-prices?batch-number={n}` — all station prices (500 stations per batch)
- `GET /api/v1/pfs?batch-number`all/incremental station metadata - `GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental, only prices changed since timestamp
- `GET /api/v1/pfs?batch-number={n}` — all station metadata (500 per batch)
- `GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental station metadata
**Fuel prices response fields** (array of stations): **Fuel prices response fields** (array of stations):
- `node_id` — station identifier - `node_id`, `public_phone_number`, `trading_name` — station identifiers
- `trading_name` — station name
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}` - `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
- Fuel types: `E5`, `E10`, `B7_STANDARD`, `B7_PREMIUM`, `B10`, `HVO` - Fuel types (API casing): `E5`, `E10`, `B7_Standard`, `B7_Premium`, `B10`, `HVO` — lowercased on ingest via `FuelType::fromApiValue()`
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence - Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
**Station metadata response fields** (array of stations): **Station metadata response fields** (array of stations):
- `node_id`, `trading_name`, `brand_name` - `node_id`, `trading_name`, `brand_name`, `is_same_trading_and_brand_name`, `public_phone_number`
- `is_supermarket_service_station`, `is_motorway_service_station` - `is_supermarket_service_station`, `is_motorway_service_station`
- `temporary_closure`, `permanent_closure` - `temporary_closure`, `permanent_closure`, `permanent_closure_date`
- `location``{address_line_1, city, postcode, latitude, longitude}` - `location``{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}`
- `amenities`string array (e.g. `car_wash`, `adblue_pumps`) - `amenities`**OBJECT** with boolean flags: `{adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}`. Normalised at ingest to a flat array of enabled keys.
- `fuel_types`string array of available fuel types - `fuel_types`**OBJECT** with boolean flags: `{E10, E5, B7_Standard, B7_Premium, B10, HVO}`. Normalised at ingest to a flat array of enabled keys.
- `opening_times`per-day open/close times (not used in scoring) - `opening_times``usual_days.{monday..sunday}.{open, close, is_24_hours}` + `bank_holidays.type.{open_time, close_time, is_24_hours}`. Stored as raw JSON, not used in scoring.
### Required-field validation
Stations missing any of `node_id`, `trading_name`, `location.postcode`, `location.latitude`, `location.longitude` are dropped at ingest with a warning. Price rows missing any of `fuel_type`, `price`, `price_last_updated`, `price_change_effective_timestamp` are skipped silently.
### Incremental polling (FuelPriceService::pollPrices)
On each successful poll the wall-clock start time is cached under `fuel_finder_last_price_poll_at` (forever). The next poll sends this as `effective-start-timestamp`. Cold start (cache miss) performs a full fetch.
### FK safety
Price batches are filtered against the `stations` table before insert — any station not yet in `stations` is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up.
### FuelPriceService responsibilities ### FuelPriceService responsibilities
1. Fetch OAuth token (cache it) 1. Fetch OAuth token (cache it)
@@ -70,7 +80,7 @@ for that `(station_id, fuel_type)` combination. Avoids row explosion on unchange
``` ```
FUEL_FINDER_CLIENT_ID= FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET= FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://api.fuel-finder.service.gov.uk FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
``` ```
## Postcodes.io — location resolution ## Postcodes.io — location resolution

View File

@@ -4,51 +4,121 @@
Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods. Laravel Cashier (Stripe). Never implement custom billing logic — use Cashier methods.
## Stripe products ## Source-of-truth spec
`docs/superpowers/specs/2026-04-23-stripe-subscription-lifecycle-design.md`
defines the full subscription lifecycle. This file is a quick-reference; the
spec document is authoritative on any contradiction.
## Stripe products & prices
Three recurring subscription products, each with monthly and annual prices:
Three recurring subscription products (monthly):
- `basic` — £0.99/mo - `basic` — £0.99/mo
- `plus` — £2.49/mo - `plus` — £2.49/mo
- `pro` — £3.99/mo - `pro` — £3.99/mo
Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from .env: Price IDs stored in `config/services.php` under `stripe.prices.*`, loaded from `.env`:
``` ```
STRIPE_PRICES_BASIC=price_xxx STRIPE_PRICE_BASIC_MONTHLY=price_xxx
STRIPE_PRICES_PLUS=price_xxx STRIPE_PRICE_BASIC_ANNUAL=price_xxx
STRIPE_PRICES_PRO=price_xxx STRIPE_PRICE_PLUS_MONTHLY=price_xxx
STRIPE_PRICE_PLUS_ANNUAL=price_xxx
STRIPE_PRICE_PRO_MONTHLY=price_xxx
STRIPE_PRICE_PRO_ANNUAL=price_xxx
``` ```
## Tier helpers (SubscriptionService) Resolution from a Cashier subscription's Stripe price ID to a plan row is done
in `Plan::resolveForUser()` — never hand-code tier lookups elsewhere.
```php ## Tier resolution
public function tier(User $user): string
// Returns 'free' | 'basic' | 'plus' | 'pro'
public function canReceiveSms(User $user): bool Use `PlanFeatures::for($user)->tier()` — returns `'free' | 'basic' | 'plus' | 'pro'`.
// true if tier is plus or pro Never inspect `$user->subscribed(...)` directly in components, notifications, or
jobs. `PlanFeatures` is the single source of entitlement truth.
public function smsRemainingThisMonth(User $user): int
// checks alerts table count for current month
```
Never check tier inline in components or notification classes — always use SubscriptionService.
## Cashier conventions ## Cashier conventions
- Billable model: `User` (add `use Billable` trait) - Billable model: `User` (uses `Billable` trait)
- Webhook route: `POST /stripe/webhook`handled by Cashier automatically - Webhook route: `POST /stripe/webhook`auto-registered by Cashier
- Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET` - Webhook secret in `.env` as `STRIPE_WEBHOOK_SECRET`
- Always handle `customer.subscription.deleted` to downgrade user to free tier - `STRIPE_KEY` and `STRIPE_SECRET` also required
- Trial: none for v1 - `CASHIER_CURRENCY=gbp`
- Trial period: none
## Upgrade / downgrade flow ## User-facing flows — all via Stripe Customer Portal
- User upgrades in account settings Livewire component **The Stripe-hosted Customer Billing Portal handles every subscription
- Swap plan with `$user->subscription()->swap($newPriceId)` management action.** Do not build custom Livewire upgrade/downgrade UIs.
- Cashier handles proration automatically
- On downgrade to free: cancel subscription, remove WhatsApp/SMS notification preference | Flow | Path |
|---|---|
| Sign up for paid tier | Pricing page → `GET /billing/checkout/{tier}/{cadence}` → Stripe Checkout |
| Upgrade | Pricing page → `GET /billing/portal` → Stripe Portal → pick higher plan → Stripe prorates, charges difference immediately |
| Downgrade | Stripe Portal → pick lower plan → Stripe schedules change at period end |
| Cancel | Stripe Portal → cancel → `cancel_at_period_end=true`; features stay until period end |
| Update card | Stripe Portal, or hosted link in Stripe's transactional dunning email |
| Reactivate after cancel / post-grace | Pricing page → Checkout (new subscription) |
Annual downgrades take effect at the end of the year — no mid-term refunds.
## Webhook handling
Single consolidated listener `HandleStripeWebhook` bound to Cashier's
`WebhookReceived` event in `AppServiceProvider`. Routes on `$event->payload['type']`:
| Event | Behaviour |
|---|---|
| `customer.subscription.created` | Bust `plan_for_user_{id}` cache |
| `customer.subscription.updated` | Bust cache |
| `customer.subscription.deleted` | Downgrade to free, disable WhatsApp + SMS prefs, clear `grace_period_until`, bust cache |
| `invoice.payment_succeeded` | Clear `grace_period_until`, bust cache |
| `invoice.payment_failed` | Set `grace_period_until = now()->addDays(5)`, queue day-3 + day-5 branded reminder mailables |
All branches must be idempotent — Stripe retries failed webhook deliveries.
`invoice.upcoming` is intentionally not handled.
## Payment failure & grace period
5-day grace window. Stripe is configured (dashboard) to retry on days 1, 3, 5
and **cancel the subscription** after the final failure.
- Features stay ON during grace — `past_due` is treated as subscribed by
Cashier, so `PlanFeatures::tier()` keeps returning the paid tier.
- After day 5 Stripe cancels → `customer.subscription.deleted` → downgrade.
- User can pay at any time via Stripe's dunning email link or the Customer
Portal — on success, grace is cleared automatically by the webhook.
## Dunning emails
- **Stripe sends:** payment-failed "update your card", successful-payment
receipts, upcoming-renewal reminders. Configure in Stripe dashboard.
- **We send:** branded reminder mailables on day 3 and day 5 after a
payment failure. Both mailables self-cancel by checking
`$this->user->grace_period_until === null` before sending — simpler than
cancelling queued jobs when payment recovers.
## Data model additions
- `users.grace_period_until` — nullable timestamp. Set on
`invoice.payment_failed`, cleared on `invoice.payment_succeeded` or
`customer.subscription.deleted`. Drives the dashboard past-due banner.
No other schema additions. Cashier + Stripe are the source of truth for
subscription state.
## VAT / Stripe Tax
Not enabled for v1. Revisit before £90k/year turnover (~£1.88k/month at
£3.99 avg, or ~470 paying pro users).
## Stripe test mode ## Stripe test mode
Use Stripe test keys in local `.env`. Never commit real Stripe keys. Use Stripe test keys in local `.env`. Never commit real Stripe keys.
Test cards: 4242424242424242 (success), 4000000000000002 (decline).
Test cards:
- `4242 4242 4242 4242` — success
- `4000 0000 0000 0002` — generic decline
- `4000 0000 0000 0341` — renewal charge fails (use to test dunning flow)

211
.claude/rules/prediction.md Normal file
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"
---

View File

@@ -103,11 +103,12 @@ Each tier has two prices:
### `plans` table ### `plans` table
``` ```
id unsignedBigInteger PK id unsignedBigInteger PK
name string — free | basic | plus | pro name string — free | basic | plus | pro
stripe_price_id string nullable — maps Cashier price to this plan stripe_price_id_monthly string nullable — Cashier price ID for monthly billing
features json — see shape below stripe_price_id_annual string nullable — Cashier price ID for annual billing
active boolean default true features json — see shape below
active boolean default true
timestamps timestamps
``` ```
@@ -123,7 +124,8 @@ timestamps
"frequency": "triggered" "frequency": "triggered"
}, },
"push": { "push": {
"enabled": true "enabled": true,
"frequency": "triggered"
}, },
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
@@ -141,7 +143,20 @@ timestamps
``` ```
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`, `fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
`daily`, `triggered`. All boolean features default `false` on free. `daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`,
`triggered`. All boolean features default `false` on free.
`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality:
- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only).
- `ai_predictions` is **plus and pro only**.
- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`)
even when `enabled: false` — set to `0` on disabled tiers.
> Deeper per-tier feature flags (history window, prediction level, leaderboard size,
> saved stations, fuel log caps, brand comparison, route planner, family sharing) are
> defined in `docs/superpowers/specs/2026-04-15-tier-features-design.md` — that spec
> is the source of truth for entitlements beyond notification channels.
### `user_notification_preferences` table ### `user_notification_preferences` table
@@ -184,8 +199,8 @@ missed-count queries.
- Casts `features` to `array`. - Casts `features` to `array`.
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's - Has a static `resolveForUser(User $user): Plan` method — looks up the user's
active Cashier subscription price ID, matches to `stripe_price_id`, falls back active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
to the `free` plan row. or `stripe_price_id_annual`, falls back to the `free` plan row.
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`. - Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`. - Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.

30
.claude/settings.json Normal file
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 Alert"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://fuel-alert.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=fuel-price
# DB_USERNAME=root DB_USERNAME=fuel-price
# DB_PASSWORD= DB_PASSWORD=password
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=.fuel-alert.test
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -64,14 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
FUELALERT_API_KEY= FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY= FRED_API_KEY=
EIA_API_KEY= # US EIA Open Data API key — register free at eia.gov/opendata
STRIPE_PRICE_BASIC_MONTHLY= ONESIGNAL_APP_ID=
STRIPE_PRICE_BASIC_ANNUAL= ONESIGNAL_API_KEY=
STRIPE_PRICE_PLUS_MONTHLY=
STRIPE_PRICE_PLUS_ANNUAL= VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=FuelAlert
API_SECRET_KEY=
EIA_API_KEY=
LLM_PREDICTION_PROVIDER=anthropic
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=gbp
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY= STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL= STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-alert.test

3
.gitignore vendored
View File

@@ -1,3 +1,4 @@
.DS_Store
/.phpunit.cache /.phpunit.cache
/node_modules /node_modules
/public/build /public/build
@@ -22,3 +23,5 @@ yarn-error.log
/.vscode /.vscode
/.zed /.zed
/.tmp/ /.tmp/
/.worktrees/
/ONSPD_Online_Latest_Centroids_*.csv

View File

@@ -1,8 +1,22 @@
# FuelAlert — Claude Code Instructions # Fuel Price — Claude Code Instructions
UK fuel price intelligence app. Subscribers receive fill-up timing recommendations UK fuel price intelligence app. Subscribers receive fill-up timing recommendations
based on local price trends. Built solo by a PHP/Laravel developer. based on local price trends. Built solo by a PHP/Laravel developer.
## Destructive DB operations — HARD STOP
**Never run** the following commands. If one of them is the right step, stop, tell the user the exact command, and ask them to run it themselves:
- `php artisan migrate:fresh` (with any flags, including `--seed`)
- `php artisan migrate:reset`
- `php artisan db:wipe`
- Raw `DROP TABLE`, `DROP DATABASE`, or `TRUNCATE` via tinker, `database-query`, or any MCP tool
- Any sequence that effectively rebuilds the schema or drops tables
These are also blocked at the harness level via `.claude/settings.json` deny rules, but the prose rule applies everywhere the block doesn't reach (compound shell commands, MCP tools, etc.).
A user saying "trust me", "do the refactor", "clean up the mess", or "I want it in db" is **not** authorisation for these — the architectural decision is separate from the operational step. If a migration is awkward to apply in-place, propose the in-place version (read JSON → populate new columns → drop the old column) instead of suggesting a rebuild. Asking once at the start of a task does not authorise repeat wipes later in the session.
## Project overview ## Project overview
- **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers - **Product**: "Fill up now or wait?" — local fuel price trend scoring for UK drivers
@@ -31,6 +45,7 @@ npm run dev # Vite asset watcher
@.claude/rules/database.md @.claude/rules/database.md
@.claude/rules/notifications.md @.claude/rules/notifications.md
@.claude/rules/scoring.md @.claude/rules/scoring.md
@.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md @.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/livewire.md

View File

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

View File

@@ -16,14 +16,4 @@ trait PasswordValidationRules
{ {
return ['required', 'string', Password::default(), 'confirmed']; return ['required', 'string', Password::default(), 'confirmed'];
} }
/**
* Get the validation rules used to validate the current password.
*
* @return array<int, Rule|array<mixed>|string>
*/
protected function currentPasswordRules(): array
{
return ['required', 'string', 'current_password'];
}
} }

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

@@ -0,0 +1,33 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPriceFetcher;
use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('oil:backfill {--from=2018-01-01 : ISO start date (inclusive)} {--to= : ISO end date (defaults to today, inclusive)}')]
#[Description('One-shot backfill of historical Brent crude prices from FRED into brent_prices.')]
class BackfillOilPrices extends Command
{
public function handle(BrentPriceFetcher $fetcher): int
{
$from = (string) $this->option('from');
$to = (string) ($this->option('to') ?: now()->toDateString());
$this->info("Backfilling Brent ({$from}{$to}) from FRED...");
try {
$count = $fetcher->backfillFromFred($from, $to);
$this->info(sprintf('Upserted %d Brent rows.', $count));
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
$this->error('FRED backfill failed: '.$e->getMessage());
return self::FAILURE;
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\VolatilityRegimeService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:evaluate-volatility')]
#[Description('Evaluate the volatility regime triggers and update volatility_regimes accordingly. Hourly cron.')]
class EvaluateVolatilityRegime extends Command
{
public function handle(VolatilityRegimeService $service): int
{
$regime = $service->evaluate();
if ($regime === null) {
$this->info('Volatility regime: OFF');
} else {
$this->info(sprintf(
'Volatility regime: ON (trigger=%s, since %s)',
$regime->trigger,
$regime->flipped_on_at->toIso8601String(),
));
}
return self::SUCCESS;
}
}

View File

@@ -7,6 +7,7 @@ use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
#[Signature('oil:fetch')] #[Signature('oil:fetch')]
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')] #[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
@@ -20,6 +21,7 @@ class FetchOilPrices extends Command
return self::SUCCESS; return self::SUCCESS;
} catch (BrentPriceFetchException $e) { } catch (BrentPriceFetchException $e) {
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...'); $this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
} }
@@ -29,6 +31,7 @@ class FetchOilPrices extends Command
return self::SUCCESS; return self::SUCCESS;
} catch (BrentPriceFetchException $e) { } catch (BrentPriceFetchException $e) {
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
$this->error('Both EIA and FRED failed: '.$e->getMessage()); $this->error('Both EIA and FRED failed: '.$e->getMessage());
return self::FAILURE; return self::FAILURE;

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\BeisImporter;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Throwable;
#[Signature('beis:import')]
#[Description('Pull the latest gov.uk Weekly road fuel prices CSV and upsert into weekly_pump_prices.')]
class ImportBeisFuelPrices extends Command
{
public function handle(BeisImporter $importer): int
{
try {
$result = $importer->import();
} catch (Throwable $e) {
$this->error('BEIS import failed: '.$e->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'Imported %d rows from %s — latest date: %s.',
$result['parsed'],
$result['csv_url'],
$result['latest_date'],
));
$this->info('Forecast cache flushed; next API hit will retrain on the new row.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Throwable;
#[Signature('postcodes:import {--file= : Path to ONSPD CSV file}')]
#[Description('Import UK postcodes (ONSPD) into the local postcodes and outcodes tables')]
final class ImportPostcodes extends Command
{
private const int CHUNK_SIZE = 1000;
public function handle(): int
{
$file = $this->option('file');
if ($file === null || ! is_readable($file)) {
$this->error('--file is required and must be a readable path to an ONSPD CSV.');
return self::FAILURE;
}
$handle = fopen($file, 'r');
if ($handle === false) {
$this->error("Unable to open {$file}.");
return self::FAILURE;
}
$header = fgetcsv($handle);
if ($header === false) {
$this->error('CSV is empty.');
fclose($handle);
return self::FAILURE;
}
$headerCounts = array_count_values(array_map('strtolower', $header));
$columns = array_change_key_case(array_flip($header), CASE_LOWER);
$pcdColumn = null;
foreach (['pcd', 'pcds', 'pcd7', 'pcd8'] as $candidate) {
if (isset($columns[$candidate])) {
$pcdColumn = $candidate;
break;
}
}
if ($pcdColumn === null) {
$this->error('Missing required postcode column (expected one of: pcd, pcds, pcd7, pcd8).');
fclose($handle);
return self::FAILURE;
}
foreach ([$pcdColumn, 'lat', 'long'] as $required) {
if (($headerCounts[$required] ?? 0) > 1) {
$this->error("Column '{$required}' appears more than once — refusing to import.");
fclose($handle);
return self::FAILURE;
}
}
foreach (['lat', 'long'] as $required) {
if (! isset($columns[$required])) {
$this->error("Missing required column '{$required}'.");
fclose($handle);
return self::FAILURE;
}
}
$hasDoterm = isset($columns['doterm']);
// Stream into a staging table first. Only swap into the live
// postcodes / outcodes tables once the full CSV has been consumed —
// a mid-stream failure leaves production data untouched.
Schema::dropIfExists('postcodes_staging');
Schema::create('postcodes_staging', function (Blueprint $table): void {
$table->string('postcode', 7);
$table->string('outcode', 4);
$table->decimal('lat', 10, 7);
$table->decimal('lng', 10, 7);
});
$buffer = [];
$imported = 0;
try {
while (($row = fgetcsv($handle)) !== false) {
if ($hasDoterm && trim((string) ($row[$columns['doterm']] ?? '')) !== '') {
continue;
}
$lat = trim((string) ($row[$columns['lat']] ?? ''));
$lng = trim((string) ($row[$columns['long']] ?? ''));
if ($lat === '' || $lng === '') {
continue;
}
$pcd = strtoupper(preg_replace('/\s+/', '', (string) $row[$columns[$pcdColumn]]));
if ($pcd === '' || strlen($pcd) < 5) {
continue;
}
$buffer[] = [
'postcode' => $pcd,
'outcode' => substr($pcd, 0, strlen($pcd) - 3),
'lat' => (float) $lat,
'lng' => (float) $lng,
];
if (count($buffer) >= self::CHUNK_SIZE) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
$buffer = [];
}
}
if ($buffer !== []) {
DB::table('postcodes_staging')->insert($buffer);
$imported += count($buffer);
}
// Swap: empty live tables, copy from staging, derive outcodes.
DB::table('outcodes')->truncate();
DB::table('postcodes')->truncate();
DB::statement(
'INSERT INTO postcodes (postcode, outcode, lat, lng)
SELECT postcode, outcode, lat, lng FROM postcodes_staging'
);
DB::statement(
'INSERT INTO outcodes (outcode, lat, lng)
SELECT outcode, AVG(lat), AVG(lng) FROM postcodes GROUP BY outcode'
);
} catch (Throwable $e) {
$this->error('Import failed — live tables left untouched: '.$e->getMessage());
return self::FAILURE;
} finally {
fclose($handle);
Schema::dropIfExists('postcodes_staging');
}
$this->info("Imported {$imported} postcodes.");
$this->info('Derived '.DB::table('outcodes')->count().' outcode centroids.');
return self::SUCCESS;
}
}

View File

@@ -3,22 +3,26 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Events\PricesUpdatedEvent; use App\Events\PricesUpdatedEvent;
use App\Models\Station;
use App\Services\FuelPriceService; use App\Services\FuelPriceService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Throwable; use Throwable;
class PollFuelPrices extends Command class PollFuelPrices extends Command
{ {
protected $signature = 'fuel:poll {--full : Also refresh station metadata}'; protected $signature = 'fuel:poll {--full : Force refresh station metadata before polling}';
protected $description = 'Poll the Fuel Finder API for latest prices'; protected $description = 'Poll the Fuel Finder API for latest prices';
public function handle(FuelPriceService $service): int public function handle(FuelPriceService $service): int
{ {
$fullRefresh = (bool) $this->option('full'); $fullRefresh = (bool) $this->option('full');
$lastRefresh = Station::max('last_seen_at');
$stationsStale = $lastRefresh === null || Carbon::parse($lastRefresh)->isBefore(today());
try { try {
if ($fullRefresh) { if ($fullRefresh || $stationsStale) {
$this->info('Refreshing station metadata...'); $this->info('Refreshing station metadata...');
$service->refreshStations(); $service->refreshStations();
} }

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPricePredictor;
use Illuminate\Console\Command;
use Throwable;
class PredictOilPrices extends Command
{
protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
protected $description = 'Generate a Brent crude oil price direction prediction';
public function handle(BrentPricePredictor $predictor): int
{
try {
$latest = $predictor->latestPrice();
if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
$message = sprintf(
'Prediction already generated for %s at %s.',
$latest->date->toDateString(),
$latest->prediction_generated_at->toDateTimeString(),
);
if (! $this->confirm($message.' Run again anyway?', default: false)) {
$this->info('Skipped.');
return self::SUCCESS;
}
}
$this->info('Generating prediction...');
$prediction = $predictor->generatePrediction();
if ($prediction === null) {
$this->error('Could not generate a prediction — not enough price data.');
return self::FAILURE;
}
$this->info(sprintf(
'Done. [%s] direction=%s confidence=%d%% — %s',
strtoupper($prediction->source->value),
$prediction->direction->value,
$prediction->confidence,
$prediction->reasoning,
));
} catch (Throwable $e) {
$this->error("Prediction failed: {$e->getMessage()}");
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\OutcomeResolver;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:resolve-outcomes')]
#[Description('Pair past weekly forecasts with the actual ULSP from BEIS data and write rows to forecast_outcomes.')]
class ResolveForecastOutcomes extends Command
{
public function handle(OutcomeResolver $resolver): int
{
$count = $resolver->resolvePending();
$this->info(sprintf('Resolved %d outstanding forecast(s).', $count));
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Services\Forecasting\LlmOverlayService;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
#[Signature('forecast:llm-overlay {--event-driven : Honor the 4h cooldown (default: false; daily 07:00 cron always runs)}')]
#[Description('Run the daily Anthropic web-search overlay on the current weekly forecast.')]
class RunLlmOverlay extends Command
{
public function handle(LlmOverlayService $service): int
{
$row = $service->run(eventDriven: (bool) $this->option('event-driven'));
if ($row === null) {
$this->warn('LLM overlay skipped (no API key, on cooldown, or rejected for empty citations).');
return self::SUCCESS;
}
$this->info(sprintf(
'Stored llm_overlays #%d — direction=%s confidence=%d major_impact=%s.',
$row->id,
$row->direction,
$row->confidence,
$row->major_impact_event ? 'YES' : 'no',
));
return self::SUCCESS;
}
}

View File

@@ -11,21 +11,20 @@ enum FuelType: string
case B10 = 'b10'; case B10 = 'b10';
case Hvo = 'hvo'; case Hvo = 'hvo';
public function label(): string
{
return match ($this) {
self::E10 => 'Petrol (E10)',
self::E5 => 'Premium (E5)',
self::B7Standard => 'Diesel (B7)',
self::B7Premium => 'Prem Diesel',
self::B10 => 'Diesel (B10)',
self::Hvo => 'HVO',
};
}
public static function fromApiValue(string $value): self public static function fromApiValue(string $value): self
{ {
return self::from(strtolower($value)); return self::from(strtolower($value));
} }
public static function fromAlias(string $alias): self
{
return match (strtolower($alias)) {
'diesel', 'b7_standard' => self::B7Standard,
'premium_diesel', 'b7_premium' => self::B7Premium,
'petrol', 'unleaded', 'e10' => self::E10,
'premium_unleaded', 'e5' => self::E5,
'b10' => self::B10,
'hvo' => self::Hvo,
default => throw new \ValueError("Unknown fuel type alias: {$alias}"),
};
}
} }

View File

@@ -8,4 +8,14 @@ enum PlanTier: string
case Basic = 'basic'; case Basic = 'basic';
case Plus = 'plus'; case Plus = 'plus';
case Pro = 'pro'; case Pro = 'pro';
public function label(): string
{
return match ($this) {
self::Free => 'Free',
self::Basic => 'Daily',
self::Plus => 'Smart',
self::Pro => 'Pro',
};
}
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Enums;
use Illuminate\Support\Carbon;
enum PriceReliability: string
{
case Reliable = 'reliable';
case Stale = 'stale';
case Outdated = 'outdated';
public static function fromUpdatedAt(?Carbon $updatedAt): self
{
if ($updatedAt === null) {
return self::Outdated;
}
$hours = $updatedAt->diffInHours(now());
return match (true) {
$hours <= 72 => self::Reliable,
$hours <= 168 => self::Stale,
default => self::Outdated,
};
}
public function weight(): int
{
return match ($this) {
self::Reliable => 0,
self::Stale => 1,
self::Outdated => 2,
};
}
public function label(): string
{
return match ($this) {
self::Reliable => 'Reliable',
self::Stale => 'Older price — verify before driving',
self::Outdated => 'Outdated — may be inaccurate',
};
}
}

View File

@@ -13,6 +13,8 @@ enum NavigationGroup implements HasIcon, HasLabel
case Data; case Data;
case Forecasting;
case System; case System;
public function getLabel(): string public function getLabel(): string
@@ -20,6 +22,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) { return match ($this) {
self::Users => 'Users', self::Users => 'Users',
self::Data => 'Data', self::Data => 'Data',
self::Forecasting => 'Forecasting',
self::System => 'System', self::System => 'System',
}; };
} }
@@ -29,6 +32,7 @@ enum NavigationGroup implements HasIcon, HasLabel
return match ($this) { return match ($this) {
self::Users => 'heroicon-o-users', self::Users => 'heroicon-o-users',
self::Data => 'heroicon-o-circle-stack', self::Data => 'heroicon-o-circle-stack',
self::Forecasting => null,
self::System => 'heroicon-o-cog-6-tooth', self::System => 'heroicon-o-cog-6-tooth',
}; };
} }

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\Backtests;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Backtests\Pages\ListBacktests;
use App\Filament\Resources\Backtests\Pages\ViewBacktest;
use App\Filament\Resources\Backtests\Schemas\BacktestInfolist;
use App\Filament\Resources\Backtests\Tables\BacktestsTable;
use App\Models\Backtest;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class BacktestResource extends Resource
{
protected static ?string $model = Backtest::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedBeaker;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Backtests';
protected static ?int $navigationSort = 3;
public static function infolist(Schema $schema): Schema
{
return BacktestInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return BacktestsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListBacktests::route('/'),
'view' => ViewBacktest::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ListRecords;
class ListBacktests extends ListRecords
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\Backtests\Pages;
use App\Filament\Resources\Backtests\BacktestResource;
use Filament\Resources\Pages\ViewRecord;
class ViewBacktest extends ViewRecord
{
protected static string $resource = BacktestResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,89 @@
<?php
namespace App\Filament\Resources\Backtests\Schemas;
use App\Models\Backtest;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\KeyValueEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class BacktestInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Run')->columns(3)->schema([
TextEntry::make('model_version')->columnSpanFull(),
TextEntry::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%'),
TextEntry::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p'),
IconEntry::make('leak_suspected')
->label('Leak suspected')
->boolean()
->trueColor('danger'),
TextEntry::make('train_start')->date('d M Y'),
TextEntry::make('train_end')->date('d M Y'),
TextEntry::make('eval_start')->date('d M Y'),
TextEntry::make('eval_end')->date('d M Y'),
TextEntry::make('ran_at')->dateTime('d M Y H:i'),
]),
Section::make('Calibration table')
->description('Empirical hit rate per magnitude bin from the eval window.')
->schema([
KeyValueEntry::make('calibration_table')
->hiddenLabel()
->keyLabel('Magnitude bin')
->valueLabel('Empirical hit rate')
->state(fn (Backtest $record): array => collect($record->calibration_table ?? [])
->mapWithKeys(fn ($value, $key) => [$key => round((float) $value * 100, 1).'%'])
->all())
->columnSpanFull(),
]),
Section::make('Feature spec')->schema([
KeyValueEntry::make('features_json')
->hiddenLabel()
->state(fn (Backtest $record): array => self::flattenForKeyValue($record->features_json))
->columnSpanFull(),
]),
Section::make('Coefficients')
->visible(fn (Backtest $record) => $record->coefficients_json !== null)
->collapsed()
->schema([
TextEntry::make('coefficients_json')
->hiddenLabel()
->state(fn (Backtest $record): string => json_encode(
$record->coefficients_json,
JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
) ?: '')
->columnSpanFull(),
]),
]);
}
/**
* KeyValueEntry expects a flat string-keyed map, so collapse nested arrays
* into JSON strings rather than dropping them.
*
* @param array<string, mixed>|null $features
* @return array<string, string>
*/
protected static function flattenForKeyValue(?array $features): array
{
return collect($features ?? [])
->mapWithKeys(fn ($value, $key) => [
(string) $key => is_scalar($value)
? (string) $value
: (json_encode($value, JSON_UNESCAPED_SLASHES) ?: ''),
])
->all();
}
}

View File

@@ -0,0 +1,94 @@
<?php
namespace App\Filament\Resources\Backtests\Tables;
use App\Models\Backtest;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class BacktestsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('model_version')
->searchable()
->limit(32)
->tooltip(fn (Backtest $record) => strlen($record->model_version) > 32 ? $record->model_version : null),
TextColumn::make('directional_accuracy')
->label('Accuracy')
->state(fn (Backtest $record): string => $record->directional_accuracy === null
? '—'
: round((float) $record->directional_accuracy, 1).'%')
->color(fn (Backtest $record) => self::accuracyColor($record))
->sortable(),
TextColumn::make('mae_pence')
->label('MAE')
->state(fn (Backtest $record): string => $record->mae_pence === null
? '—'
: number_format((float) $record->mae_pence, 2).'p')
->sortable(),
IconColumn::make('leak_suspected')
->label('Leak?')
->boolean()
->trueColor('danger'),
TextColumn::make('eval_start')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('eval_end')
->date('d M Y')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('ran_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('ran_at', 'desc')
->filters([
Filter::make('leak_suspected')
->label('Suspicious accuracy (leak suspected)')
->toggle()
->query(fn (Builder $query) => $query->where('leak_suspected', true)),
Filter::make('below_ship_gate')
->label('Below ship gate')
->toggle()
->query(fn (Builder $query) => $query->where('directional_accuracy', '<', 62)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function accuracyColor(Backtest $record): ?string
{
if ($record->directional_accuracy === null) {
return null;
}
$accuracy = (float) $record->directional_accuracy;
if ($accuracy > 75 && $record->leak_suspected) {
return 'danger';
}
if ($accuracy < 60) {
return 'danger';
}
if ($accuracy < 62) {
return 'warning';
}
if ($accuracy <= 75) {
return 'success';
}
return null;
}
}

View File

@@ -1,141 +0,0 @@
<?php
namespace App\Filament\Resources;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Filament\NavigationGroup;
use App\Filament\Resources\OilPredictionResource\Pages\ListOilPredictions;
use App\Filament\Resources\OilPredictionResource\Pages\ViewOilPrediction;
use App\Models\PricePrediction;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\DatePicker;
use Filament\Infolists\Components\TextEntry;
use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class OilPredictionResource extends Resource
{
protected static ?string $model = PricePrediction::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Data;
protected static ?string $navigationLabel = 'Oil Predictions';
protected static ?int $navigationSort = 3;
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('predicted_for')
->date('d M Y')
->sortable(),
TextColumn::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextColumn::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextColumn::make('confidence')
->suffix('%')
->sortable(),
TextColumn::make('reasoning')
->limit(60)
->placeholder('—'),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable(),
])
->defaultSort('predicted_for', 'desc')
->filters([
SelectFilter::make('source')
->options([
PredictionSource::Llm->value => 'LLM',
PredictionSource::LlmWithContext->value => 'LLM + Context',
PredictionSource::Ewma->value => 'EWMA',
]),
SelectFilter::make('direction')
->options([
TrendDirection::Rising->value => 'Rising',
TrendDirection::Falling->value => 'Falling',
TrendDirection::Flat->value => 'Flat',
]),
Filter::make('predicted_for')
->schema([
DatePicker::make('from')->label('From'),
DatePicker::make('until')->label('Until'),
])
->query(function (Builder $query, array $data) {
$query
->when($data['from'], fn ($q, $d) => $q->whereDate('predicted_for', '>=', $d))
->when($data['until'], fn ($q, $d) => $q->whereDate('predicted_for', '<=', $d));
}),
])
->recordActions([
ViewAction::make(),
]);
}
public static function infolist(Schema $schema): Schema
{
return $schema->components([
Section::make('Prediction')->schema([
TextEntry::make('predicted_for')->date('d M Y'),
TextEntry::make('source')
->badge()
->formatStateUsing(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'LLM',
PredictionSource::LlmWithContext => 'LLM + Context',
PredictionSource::Ewma => 'EWMA',
})
->color(fn (PredictionSource $state) => match ($state) {
PredictionSource::Llm => 'success',
PredictionSource::LlmWithContext => 'warning',
PredictionSource::Ewma => 'info',
}),
TextEntry::make('direction')
->badge()
->color(fn (TrendDirection $state) => match ($state) {
TrendDirection::Rising => 'danger',
TrendDirection::Falling => 'success',
TrendDirection::Flat => 'gray',
}),
TextEntry::make('confidence')->suffix('%'),
TextEntry::make('generated_at')->dateTime('d M Y H:i:s'),
])->columns(3),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded'),
]),
]);
}
public static function getPages(): array
{
return [
'index' => ListOilPredictions::route('/'),
'view' => ViewOilPrediction::route('/{record}'),
];
}
}

View File

@@ -1,42 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListOilPredictions extends ListRecords
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [
Action::make('runPrediction')
->label('Run Prediction Now')
->icon('heroicon-o-cpu-chip')
->requiresConfirmation()
->modalHeading('Run oil price prediction?')
->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
->action(function () {
$result = Artisan::call('oil:predict', ['--force' => true]);
if ($result === 0) {
Notification::make()
->title('Prediction generated successfully')
->success()
->send();
} else {
Notification::make()
->title('Prediction failed')
->body('Check API Logs for details.')
->danger()
->send();
}
}),
];
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Filament\Resources\OilPredictionResource\Pages;
use App\Filament\Resources\OilPredictionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewOilPrediction extends ViewRecord
{
protected static string $resource = OilPredictionResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

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

View File

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

View File

@@ -2,20 +2,29 @@
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\FuelType;
use App\Enums\PlanTier;
use App\Filament\NavigationGroup; use App\Filament\NavigationGroup;
use App\Filament\Resources\UserResource\Pages\EditUser; use App\Filament\Resources\UserResource\Pages\EditUser;
use App\Filament\Resources\UserResource\Pages\ListUsers; use App\Filament\Resources\UserResource\Pages\ListUsers;
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Services\PlanFeatures;
use Filament\Actions\DeleteAction; use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle; use Filament\Forms\Components\Toggle;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema; use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn; use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
class UserResource extends Resource class UserResource extends Resource
{ {
@@ -28,12 +37,89 @@ class UserResource extends Resource
public static function form(Schema $schema): Schema public static function form(Schema $schema): Schema
{ {
return $schema->components([ return $schema->components([
Toggle::make('is_admin') Section::make('Profile')->columns(2)->schema([
->label('Admin') TextInput::make('name')
->helperText('Grants access to this admin panel.'), ->required()
TextInput::make('postcode') ->maxLength(255),
->label('Postcode') TextInput::make('email')
->maxLength(8), ->email()
->required()
->maxLength(255),
TextInput::make('postcode')
->maxLength(8),
Select::make('preferred_fuel_type')
->label('Preferred fuel type')
->options(collect(FuelType::cases())
->mapWithKeys(fn (FuelType $t) => [$t->value => $t->label()])
->all())
->required(),
]),
Section::make('Access')->columns(2)->schema([
Toggle::make('is_admin')
->label('Admin')
->helperText('Grants access to this admin panel.'),
DateTimePicker::make('email_verified_at')
->label('Email verified at'),
]),
Section::make('Subscription')->columns(2)->schema([
Select::make('tier')
->label('Tier')
->options([
PlanTier::Free->value => 'Free',
PlanTier::Basic->value => 'Basic',
PlanTier::Plus->value => 'Plus',
PlanTier::Pro->value => 'Pro',
])
->required()
->live()
->dehydrated(false)
->afterStateHydrated(fn (Select $component, ?User $record) => $component
->state($record ? PlanFeatures::for($record)->tier() : PlanTier::Free->value)),
Select::make('cadence')
->label('Billing Cadence')
->options([
'monthly' => 'Monthly',
'annual' => 'Annual',
])
->default('monthly')
->dehydrated(false)
->visible(fn (callable $get): bool => ($get('tier') ?? '') !== PlanTier::Free->value)
->helperText('Only applies when assigning a paid tier. Real Stripe subscriptions are not modified.'),
]),
Section::make('Security')->columns(2)->schema([
DateTimePicker::make('two_factor_confirmed_at')
->label('2FA confirmed at')
->disabled(),
TextInput::make('password')
->label('Set new password')
->password()
->revealable()
->minLength(8)
->dehydrated(fn (?string $state): bool => filled($state))
->dehydrateStateUsing(fn (string $state): string => bcrypt($state))
->helperText('Leave blank to keep current password.')
->afterStateHydrated(fn (TextInput $component) => $component->state(null)),
]),
Section::make('Billing')->columns(3)->schema([
TextInput::make('stripe_id')
->label('Stripe customer ID')
->disabled(),
TextInput::make('pm_type')
->label('Payment method')
->disabled(),
TextInput::make('pm_last_four')
->label('Card last 4')
->disabled(),
]),
Section::make('Timestamps')->columns(2)->schema([
DateTimePicker::make('created_at')->disabled(),
DateTimePicker::make('updated_at')->disabled(),
]),
]); ]);
} }
@@ -44,6 +130,16 @@ class UserResource extends Resource
TextColumn::make('name')->searchable()->sortable(), TextColumn::make('name')->searchable()->sortable(),
TextColumn::make('email')->searchable()->sortable(), TextColumn::make('email')->searchable()->sortable(),
TextColumn::make('postcode')->placeholder('—'), TextColumn::make('postcode')->placeholder('—'),
TextColumn::make('tier')
->label('Tier')
->state(fn (User $record): string => PlanFeatures::for($record)->tier())
->badge()
->colors([
'gray' => 'free',
'primary' => 'basic',
'warning' => 'plus',
'success' => 'pro',
]),
IconColumn::make('is_admin') IconColumn::make('is_admin')
->label('Admin') ->label('Admin')
->boolean(), ->boolean(),
@@ -62,6 +158,60 @@ class UserResource extends Resource
]); ]);
} }
/**
* Cancel any existing admin-granted subscription, then (if a paid tier
* was requested) insert a fresh synthetic active subscription row.
*/
public static function applyTier(User $user, string $tier, string $cadence): void
{
$hasRealStripeSubscription = $user->subscriptions()
->where('stripe_id', 'not like', 'admin_%')
->whereIn('stripe_status', ['active', 'trialing', 'past_due'])
->exists();
if ($hasRealStripeSubscription) {
throw new \RuntimeException(
"User {$user->email} has an active Stripe subscription — modify it through the Stripe dashboard, not the admin panel."
);
}
$user->subscriptions()->where('stripe_id', 'like', 'admin_%')->delete();
if ($tier === PlanTier::Free->value) {
self::bustPlanCache($user);
return;
}
$priceId = config("services.stripe.prices.{$tier}.{$cadence}") ?: "price_admin_{$tier}_{$cadence}";
$planColumn = $cadence === 'annual' ? 'stripe_price_id_annual' : 'stripe_price_id_monthly';
$plan = Plan::where('name', $tier)->first();
if ($plan && empty($plan->{$planColumn})) {
$plan->update([$planColumn => $priceId]);
}
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'admin_'.Str::uuid(),
'stripe_status' => 'active',
'stripe_price' => $priceId,
'quantity' => 1,
]);
self::bustPlanCache($user);
}
protected static function bustPlanCache(User $user): void
{
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
} else {
Cache::forget("plan_for_user_{$user->id}");
}
}
public static function getPages(): array public static function getPages(): array
{ {
return [ return [

View File

@@ -3,14 +3,52 @@
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Filament\Resources\UserResource\Widgets\MissedNotificationsOverview;
use App\Models\User;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord; use Filament\Resources\Pages\EditRecord;
class EditUser extends EditRecord class EditUser extends EditRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected function getHeaderWidgets(): array
{
return [
MissedNotificationsOverview::class,
];
}
public function getHeaderWidgetsColumns(): int|array
{
return 3;
}
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [];
} }
protected function afterSave(): void
{
/** @var User $user */
$user = $this->record;
$tier = $this->data['tier'] ?? null;
$cadence = $this->data['cadence'] ?? 'monthly';
if ($tier === null) {
return;
}
try {
UserResource::applyTier($user, $tier, $cadence);
} catch (\RuntimeException $e) {
Notification::make()
->title('Tier not changed')
->body($e->getMessage())
->warning()
->send();
}
}
} }

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Filament\Resources\UserResource\Widgets;
use App\Models\NotificationLog;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
class MissedNotificationsOverview extends StatsOverviewWidget
{
public ?User $record = null;
protected function getStats(): array
{
if ($this->record === null) {
return [];
}
$userId = $this->record->id;
$missedTodayByChannel = fn (string $channel): int => NotificationLog::where('user_id', $userId)
->where('channel', $channel)
->where('sent', false)
->whereDate('created_at', today())
->count();
$missedThisMonth = NotificationLog::where('user_id', $userId)
->where('sent', false)
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
return [
Stat::make('SMS missed today', $missedTodayByChannel('sms'))
->color($missedTodayByChannel('sms') > 0 ? 'warning' : 'gray'),
Stat::make('WhatsApp missed today', $missedTodayByChannel('whatsapp'))
->color($missedTodayByChannel('whatsapp') > 0 ? 'warning' : 'gray'),
Stat::make('Total missed this month', $missedThisMonth)
->color($missedThisMonth > 0 ? 'warning' : 'gray'),
];
}
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Resources\Pages\CreateRecord;
class CreateWatchedEvent extends CreateRecord
{
protected static string $resource = WatchedEventResource::class;
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\DeleteAction;
use Filament\Resources\Pages\EditRecord;
class EditWatchedEvent extends EditRecord
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
DeleteAction::make(),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Pages;
use App\Filament\Resources\WatchedEvents\WatchedEventResource;
use Filament\Actions\CreateAction;
use Filament\Resources\Pages\ListRecords;
class ListWatchedEvents extends ListRecords
{
protected static string $resource = WatchedEventResource::class;
protected function getHeaderActions(): array
{
return [
CreateAction::make(),
];
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Schemas;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Schemas\Schema;
class WatchedEventForm
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
TextInput::make('label')
->required()
->maxLength(128)
->helperText('Short geopolitical event label, e.g. "Iran tensions AprMay 2026".'),
DateTimePicker::make('starts_at')
->label('Starts at')
->required(),
DateTimePicker::make('ends_at')
->label('Ends at')
->required()
->after('starts_at'),
Textarea::make('notes')
->maxLength(2000)
->rows(4)
->columnSpanFull(),
]);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace App\Filament\Resources\WatchedEvents\Tables;
use App\Models\WatchedEvent;
use Filament\Actions\BulkActionGroup;
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WatchedEventsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('label')
->searchable()
->sortable()
->limit(60)
->tooltip(fn (WatchedEvent $record) => strlen($record->label) > 60 ? $record->label : null),
TextColumn::make('starts_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('ends_at')
->dateTime('d M Y H:i')
->sortable(),
TextColumn::make('status')
->label('Status')
->badge()
->state(fn (WatchedEvent $record): string => self::isActive($record) ? 'Active' : 'Inactive')
->color(fn (string $state) => $state === 'Active' ? 'success' : 'gray'),
TextColumn::make('notes')
->limit(50)
->placeholder('—')
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('starts_at', 'desc')
->filters([
Filter::make('currently_active')
->label('Currently active')
->toggle()
->query(fn (Builder $query) => $query
->where('starts_at', '<=', now())
->where('ends_at', '>=', now())),
])
->recordActions([
EditAction::make(),
DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
DeleteBulkAction::make(),
]),
]);
}
protected static function isActive(WatchedEvent $record): bool
{
$now = now();
return $record->starts_at !== null
&& $record->ends_at !== null
&& $record->starts_at->lessThanOrEqualTo($now)
&& $record->ends_at->greaterThanOrEqualTo($now);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Filament\Resources\WatchedEvents;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WatchedEvents\Pages\CreateWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\EditWatchedEvent;
use App\Filament\Resources\WatchedEvents\Pages\ListWatchedEvents;
use App\Filament\Resources\WatchedEvents\Schemas\WatchedEventForm;
use App\Filament\Resources\WatchedEvents\Tables\WatchedEventsTable;
use App\Models\WatchedEvent;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class WatchedEventResource extends Resource
{
protected static ?string $model = WatchedEvent::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedFlag;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Watched Events';
protected static ?int $navigationSort = 1;
public static function form(Schema $schema): Schema
{
return WatchedEventForm::configure($schema);
}
public static function table(Table $table): Table
{
return WatchedEventsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListWatchedEvents::route('/'),
'create' => CreateWatchedEvent::route('/create'),
'edit' => EditWatchedEvent::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ListRecords;
class ListWeeklyForecasts extends ListRecords
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Pages;
use App\Filament\Resources\WeeklyForecasts\WeeklyForecastResource;
use Filament\Resources\Pages\ViewRecord;
class ViewWeeklyForecast extends ViewRecord
{
protected static string $resource = WeeklyForecastResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Schemas;
use App\Models\WeeklyForecast;
use Filament\Infolists\Components\IconEntry;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class WeeklyForecastInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema->components([
Section::make('Forecast')->columns(3)->schema([
TextEntry::make('forecast_for')->date('d M Y'),
TextEntry::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextEntry::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence)),
TextEntry::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null),
IconEntry::make('flagged_duty_change')
->label('Duty change adjacent')
->boolean()
->trueColor('warning'),
TextEntry::make('generated_at')->dateTime('d M Y H:i'),
]),
Section::make('Reasoning')->schema([
TextEntry::make('reasoning')
->columnSpanFull()
->placeholder('No reasoning recorded.'),
]),
Section::make('Model')
->description('Calibration table from the matching backtest determines the displayed confidence.')
->schema([
TextEntry::make('model_version')->columnSpanFull(),
]),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts\Tables;
use App\Models\WeeklyForecast;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\Filter;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
class WeeklyForecastsTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('forecast_for')
->label('Forecast for')
->date('d M Y')
->sortable(),
TextColumn::make('direction')
->badge()
->color(fn (string $state) => match ($state) {
'rising' => 'warning',
'falling' => 'success',
default => 'gray',
}),
TextColumn::make('magnitude_pence')
->label('Magnitude')
->state(fn (WeeklyForecast $record): string => self::formatMagnitude($record->magnitude_pence))
->sortable(),
TextColumn::make('ridge_confidence')
->label('Confidence')
->state(fn (WeeklyForecast $record): string => $record->ridge_confidence.'%')
->color(fn (WeeklyForecast $record) => $record->ridge_confidence < 40 ? 'warning' : null)
->sortable(),
IconColumn::make('flagged_duty_change')
->label('Duty change')
->boolean()
->trueColor('warning'),
TextColumn::make('model_version')
->searchable()
->limit(32)
->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('generated_at')
->dateTime('d M Y H:i')
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('forecast_for', 'desc')
->filters([
SelectFilter::make('direction')
->multiple()
->options([
'rising' => 'Rising',
'falling' => 'Falling',
'flat' => 'Flat',
]),
Filter::make('high_confidence')
->label('High confidence')
->toggle()
->query(fn (Builder $query) => $query->where('ridge_confidence', '>=', 70)),
Filter::make('flagged_duty_change')
->label('Duty-change-adjacent')
->toggle()
->query(fn (Builder $query) => $query->where('flagged_duty_change', true)),
])
->recordActions([
ViewAction::make(),
]);
}
protected static function formatMagnitude(?int $magnitudePence): string
{
if ($magnitudePence === null) {
return '—';
}
$pence = round($magnitudePence / 100, 1);
$sign = $pence > 0 ? '+' : '';
return $sign.$pence.'p';
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Filament\Resources\WeeklyForecasts;
use App\Filament\NavigationGroup;
use App\Filament\Resources\WeeklyForecasts\Pages\ListWeeklyForecasts;
use App\Filament\Resources\WeeklyForecasts\Pages\ViewWeeklyForecast;
use App\Filament\Resources\WeeklyForecasts\Schemas\WeeklyForecastInfolist;
use App\Filament\Resources\WeeklyForecasts\Tables\WeeklyForecastsTable;
use App\Models\WeeklyForecast;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
class WeeklyForecastResource extends Resource
{
protected static ?string $model = WeeklyForecast::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChartBar;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::Forecasting;
protected static ?string $navigationLabel = 'Weekly Forecasts';
protected static ?int $navigationSort = 2;
public static function infolist(Schema $schema): Schema
{
return WeeklyForecastInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return WeeklyForecastsTable::configure($table);
}
public static function canCreate(): bool
{
return false;
}
public static function canEdit(Model $record): bool
{
return false;
}
public static function canDelete(Model $record): bool
{
return false;
}
public static function getPages(): array
{
return [
'index' => ListWeeklyForecasts::route('/'),
'view' => ViewWeeklyForecast::route('/{record}'),
];
}
}

View File

@@ -3,10 +3,10 @@
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use App\Models\ApiLog; use App\Models\ApiLog;
use App\Models\PricePrediction;
use App\Models\Search; use App\Models\Search;
use App\Models\Station; use App\Models\Station;
use App\Models\User; use App\Models\User;
use App\Models\WeeklyForecast;
use Carbon\Carbon; use Carbon\Carbon;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +21,7 @@ class StatsOverviewWidget extends BaseWidget
$this->usersStat(), $this->usersStat(),
$this->searchesStat(), $this->searchesStat(),
$this->stationsStat(), $this->stationsStat(),
$this->oilPredictionStat(), $this->weeklyForecastStat(),
$this->apiErrorsStat(), $this->apiErrorsStat(),
]; ];
} }
@@ -56,23 +56,23 @@ class StatsOverviewWidget extends BaseWidget
->color('success'); ->color('success');
} }
private function oilPredictionStat(): Stat private function weeklyForecastStat(): Stat
{ {
$prediction = PricePrediction::bestFirst()->latest('generated_at')->first(); $forecast = WeeklyForecast::query()->latest('generated_at')->first();
if ($prediction === null) { if ($forecast === null) {
return Stat::make('Latest oil prediction', 'None') return Stat::make('Latest weekly forecast', 'None')
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color('gray'); ->color('gray');
} }
$ageHours = $prediction->generated_at->diffInHours(now()); $ageHours = $forecast->generated_at->diffInHours(now());
$color = $ageHours > 24 ? 'warning' : 'success'; $color = $ageHours > 168 ? 'warning' : 'success'; // weekly forecast → stale after a week
$value = $prediction->direction->label().' · '.$prediction->confidence.'%'; $directionLabel = ucfirst($forecast->direction);
$value = $directionLabel.' · '.$forecast->ridge_confidence.'%';
return Stat::make('Latest oil prediction', $value) return Stat::make('Latest weekly forecast', $value)
->description('Generated '.$prediction->generated_at->diffForHumans()) ->description('For week of '.$forecast->forecast_for->toDateString())
->url(route('filament.admin.resources.oil-predictions.index'))
->icon('heroicon-o-beaker') ->icon('heroicon-o-beaker')
->color($color); ->color($color);
} }

View File

@@ -3,7 +3,9 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@@ -61,6 +63,25 @@ class AuthController extends Controller
public function me(Request $request): JsonResponse public function me(Request $request): JsonResponse
{ {
return response()->json($request->user()); $user = $request->user();
if ($user === null) {
return new JsonResponse('null', json: true);
}
$subscription = $user->subscription();
$expiresAt = $subscription?->ends_at ?? $subscription?->current_period_end;
return response()->json([
'name' => $user->name,
'email' => $user->email,
'two_factor_confirmed_at' => $user->two_factor_confirmed_at?->toIso8601String(),
'tier' => PlanFeatures::for($user)->tier(),
'subscription_cancelled' => $subscription?->canceled() ?? false,
'subscription_cadence' => Plan::resolveCadenceForUser($user),
'subscribed_at' => $subscription?->created_at?->toIso8601String(),
'subscription_expires_at' => $expiresAt?->toIso8601String(),
]);
} }
} }

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; namespace App\Http\Controllers\Api;
use App\Enums\PriceClassification;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\Api\NearbyStationsRequest; use App\Http\Requests\Api\NearbyStationsRequest;
use App\Http\Resources\Api\StationResource; use App\Http\Resources\Api\StationResource;
use App\Models\Search;
use App\Models\Station;
use App\Services\PostcodeService; use App\Services\PostcodeService;
use Illuminate\Database\Query\JoinClause; use App\Services\StationSearch\SearchCriteria;
use App\Services\StationSearch\StationSearchService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
class StationController extends Controller class StationController extends Controller
{ {
public function __construct(private readonly PostcodeService $postcodeService) {} public function __construct(
private readonly PostcodeService $postcodeService,
private readonly StationSearchService $searchService,
) {}
public function index(NearbyStationsRequest $request): JsonResponse public function index(NearbyStationsRequest $request): JsonResponse
{
[$lat, $lng] = $this->resolveCoordinates($request);
$criteria = new SearchCriteria(
lat: $lat,
lng: $lng,
fuelType: $request->fuelType(),
radiusKm: $request->radius(),
sort: $request->sort(),
);
$result = $this->searchService->search(
$criteria,
$request->user(),
hash('sha256', $request->ip() ?? ''),
);
return response()->json([
'data' => StationResource::collection($result->stations),
'meta' => [
'count' => $result->stations->count(),
'fuel_type' => $criteria->fuelType->value,
'radius_km' => $criteria->radiusKm,
'lat' => $criteria->lat,
'lng' => $criteria->lng,
'lowest_pence' => $result->pricesSummary['lowest'],
'highest_pence' => $result->pricesSummary['highest'],
'cheapest_price_pence' => $result->pricesSummary['lowest'],
'avg_pence' => $result->pricesSummary['avg'],
'reliability_counts' => $result->reliabilityCounts,
],
'prediction' => $result->prediction,
]);
}
/** @return array{0: float, 1: float} */
private function resolveCoordinates(NearbyStationsRequest $request): array
{ {
if ($request->filled('postcode')) { if ($request->filled('postcode')) {
$location = $this->postcodeService->resolve($request->string('postcode')->toString()); $location = $this->postcodeService->resolve($request->string('postcode')->toString());
@@ -27,76 +64,9 @@ class StationController extends Controller
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']); throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
} }
$lat = $location->lat; return [$location->lat, $location->lng];
$lng = $location->lng;
} else {
$lat = (float) $request->input('lat');
$lng = (float) $request->input('lng');
} }
$fuelType = $request->fuelType();
$radius = $request->radius();
$sort = $request->sort();
$all = Station::query() return [(float) $request->input('lat'), (float) $request->input('lng')];
->selectRaw(
'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at,
(6371 * acos(GREATEST(-1.0, LEAST(1.0,
cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?))
+ sin(radians(?)) * sin(radians(lat))
)))) AS distance_km',
[$lat, $lng, $lat],
)
->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void {
$join->on('stations.node_id', '=', 'spc.station_id')
->where('spc.fuel_type', '=', $fuelType->value);
})
->where('stations.temporary_closure', false)
->where('stations.permanent_closure', false)
->get();
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius);
$stations = $sort === 'reliable'
? $filtered->sortBy([
fn ($s) => PriceClassification::fromUpdatedAt(
$s->price_effective_at ? Carbon::parse($s->price_effective_at) : null
)->weight(),
fn ($s) => (int) $s->price_pence,
])->values()
: $filtered->sortBy(match ($sort) {
'price' => fn ($s) => (int) $s->price_pence,
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
'brand' => fn ($s) => strtolower((string) $s->brand_name),
default => fn ($s) => (float) $s->distance_km,
})->values();
$prices = $stations->pluck('price_pence');
Search::create([
'lat_bucket' => round($lat, 2),
'lng_bucket' => round($lng, 2),
'fuel_type' => $fuelType->value,
'results_count' => $stations->count(),
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'searched_at' => now(),
'ip_hash' => hash('sha256', $request->ip() ?? ''),
]);
return response()->json([
'data' => StationResource::collection($stations),
'meta' => [
'count' => $stations->count(),
'fuel_type' => $fuelType->value,
'radius_km' => $radius,
'lat' => $lat,
'lng' => $lng,
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'cheapest_price_pence' => $prices->min(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
],
]);
} }
} }

View File

@@ -4,11 +4,33 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Search; use App\Models\Search;
use App\Models\Station;
use App\Models\StationPrice;
use Carbon\CarbonImmutable;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
class StatsController extends Controller class StatsController extends Controller
{ {
public function live(): JsonResponse
{
$payload = Cache::remember('api:stats:live', now()->addMinutes(5), function (): array {
$stationCount = Station::query()
->where('permanent_closure', false)
->count();
$latestPriceAt = StationPrice::query()->max('recorded_at');
return [
'station_count' => $stationCount,
'latest_price_at' => $latestPriceAt ? CarbonImmutable::parse($latestPriceAt)->toIso8601String() : null,
];
});
return response()->json($payload);
}
public function searches(Request $request): JsonResponse public function searches(Request $request): JsonResponse
{ {
$period = $request->input('period', 'week'); $period = $request->input('period', 'week');

View File

@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Enums\FuelType;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -25,7 +26,7 @@ final class UserController extends Controller
public function updatePreferences(Request $request): JsonResponse public function updatePreferences(Request $request): JsonResponse
{ {
$validated = $request->validate([ $validated = $request->validate([
'preferred_fuel_type' => ['sometimes', Rule::in(['petrol', 'diesel', 'e5', 'b7_premium', 'b10', 'hvo'])], 'preferred_fuel_type' => ['sometimes', Rule::in(array_column(FuelType::cases(), 'value'))],
'postcode' => ['sometimes', 'string', 'max:8'], 'postcode' => ['sometimes', 'string', 'max:8'],
]); ]);

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers;
use App\Enums\PlanTier;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Cashier\Checkout;
use Symfony\Component\HttpFoundation\Response;
class BillingController extends Controller
{
/**
* Redirect the user to a Stripe Checkout session for the requested plan + cadence.
*/
public function checkout(Request $request, string $tier, string $cadence): Response|RedirectResponse|Checkout
{
abort_unless(in_array($tier, [PlanTier::Basic->value, PlanTier::Plus->value, PlanTier::Pro->value], true), 404);
abort_unless(in_array($cadence, ['monthly', 'annual'], true), 404);
$priceId = config("services.stripe.prices.{$tier}.{$cadence}");
abort_if(empty($priceId), 404, "No Stripe price configured for {$tier} {$cadence}");
return $request->user()
->newSubscription('default', $priceId)
->allowPromotionCodes()
->checkout([
'success_url' => route('billing.success').'?session_id={CHECKOUT_SESSION_ID}',
'cancel_url' => route('billing.cancel'),
]);
}
/** Redirect the user to the Stripe-hosted Customer Billing Portal. */
public function portal(Request $request): Response|RedirectResponse
{
return $request->user()->redirectToBillingPortal(route('dashboard'));
}
public function success(): RedirectResponse
{
return redirect()->route('dashboard')->with('status', 'subscription_started');
}
public function cancel(): RedirectResponse
{
return redirect()->route('dashboard')->with('status', 'subscription_cancelled');
}
}

View File

@@ -27,7 +27,7 @@ class NearbyStationsRequest extends FormRequest
public function fuelType(): FuelType public function fuelType(): FuelType
{ {
return FuelType::fromAlias($this->string('fuel_type')->toString()); return FuelType::from(strtolower($this->string('fuel_type')->toString()));
} }
public function radius(): float public function radius(): float
@@ -37,6 +37,6 @@ class NearbyStationsRequest extends FormRequest
public function sort(): string public function sort(): string
{ {
return $this->input('sort', 'price'); return $this->input('sort', 'reliable');
} }
} }

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

@@ -3,6 +3,7 @@
namespace App\Http\Resources\Api; namespace App\Http\Resources\Api;
use App\Enums\PriceClassification; use App\Enums\PriceClassification;
use App\Enums\PriceReliability;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -11,28 +12,82 @@ class StationResource extends JsonResource
{ {
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
// The controller pre-computes _updated_at / _reliability / _classification
// per row. Falling back to fresh computation keeps the resource usable
// outside that path (e.g. tests or future callers).
$updatedAt = $this->_updated_at
?? ($this->price_effective_at ? Carbon::parse($this->price_effective_at) : null);
$reliability = $this->_reliability ?? PriceReliability::fromUpdatedAt($updatedAt);
$classification = $this->_classification ?? PriceClassification::fromUpdatedAt($updatedAt);
return [ return [
'station_id' => $this->node_id, 'station_id' => $this->node_id,
'name' => $this->trading_name, 'name' => $this->trading_name,
'brand' => $this->brand_name, 'brand' => $this->brand_name,
'is_supermarket' => (bool) $this->is_supermarket, 'is_supermarket' => (bool) $this->is_supermarket,
'is_motorway' => (bool) $this->is_motorway_service_station,
'address' => implode(', ', array_filter([$this->address_line_1, $this->city])), 'address' => implode(', ', array_filter([$this->address_line_1, $this->city])),
'postcode' => $this->postcode, 'postcode' => $this->postcode,
'lat' => (float) $this->lat, 'lat' => (float) $this->lat,
'lng' => (float) $this->lng, 'lng' => (float) $this->lng,
'distance_km' => round((float) $this->distance_km, 2), 'distance_km' => round((float) $this->distance_km, 2),
'fuel_type' => $this->fuel_type, 'fuel_type' => $this->fuel_type,
'fuel_types_available' => $this->fuel_types ?? [],
'amenities' => $this->amenities ?? [],
'open_today' => $this->openTodayPayload(),
'price_pence' => (int) $this->price_pence, 'price_pence' => (int) $this->price_pence,
'price' => round((int) $this->price_pence / 100, 2), 'price' => round((int) $this->price_pence / 100, 2),
'price_updated_at' => $this->price_effective_at 'price_updated_at' => $updatedAt?->toISOString(),
? Carbon::parse($this->price_effective_at)->toISOString() 'price_classification' => $classification->value,
: null, 'price_classification_label' => $classification->label(),
'price_classification' => PriceClassification::fromUpdatedAt( 'reliability' => $reliability->value,
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null 'reliability_label' => $reliability->label(),
)->value,
'price_classification_label' => PriceClassification::fromUpdatedAt(
$this->price_effective_at ? Carbon::parse($this->price_effective_at) : null
)->label(),
]; ];
} }
/**
* @return array{is_24_hours: bool, open: ?string, close: ?string, is_open_now: bool}|null
*/
private function openTodayPayload(): ?array
{
$times = $this->opening_times;
if (! is_array($times) || empty($times['usual_days'])) {
return null;
}
$now = Carbon::now('Europe/London');
$dayKey = strtolower($now->format('l'));
$today = $times['usual_days'][$dayKey] ?? null;
if (! is_array($today)) {
return null;
}
$is24 = (bool) ($today['is_24_hours'] ?? false);
$open = $today['open'] ?? null;
$close = $today['close'] ?? null;
return [
'is_24_hours' => $is24,
'open' => $open ? substr($open, 0, 5) : null,
'close' => $close ? substr($close, 0, 5) : null,
'is_open_now' => $this->computeIsOpenNow($is24, $open, $close, $now),
];
}
private function computeIsOpenNow(bool $is24, ?string $open, ?string $close, Carbon $now): bool
{
if ($is24) {
return true;
}
if (! $open || ! $close) {
return false;
}
$current = $now->format('H:i:s');
return $current >= $open && $current < $close;
}
} }

View File

@@ -5,15 +5,15 @@ namespace App\Jobs;
use App\Models\NotificationLog; use App\Models\NotificationLog;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use App\Notifications\FuelPriceAlert;
use App\Services\PlanFeatures; use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/** /**
* Resolves allowed notification channels for a user and trigger, sends * Resolves allowed notification channels for a user and trigger, dispatches
* notifications, and logs every outcome (sent, daily_limit, tier_restricted). * the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
* * SMS), and logs every outcome (sent, daily_limit, tier_restricted).
* Actual sending is stubbed until FuelPriceAlert notification class exists.
*/ */
final class DispatchUserNotificationJob implements ShouldQueue final class DispatchUserNotificationJob implements ShouldQueue
{ {
@@ -38,9 +38,21 @@ final class DispatchUserNotificationJob implements ShouldQueue
// Step 3: channels that pass tier + user-pref + daily-limit checks // Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType); $allowed = $features->channelsFor($this->triggerType);
// Step 4: send and log sent notifications // Step 4: dispatch the multi-channel notification — Laravel fans out
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
if ($allowed !== []) {
$this->user->notify(new FuelPriceAlert(
$this->triggerType,
$this->fuelType,
$this->price,
$allowed,
));
}
// Step 5: log a sent entry per allowed channel. The notify() call
// above queues per-channel sends; per-channel HTTP outcomes are
// captured in api_logs by the channel adapters themselves.
foreach ($allowed as $channel) { foreach ($allowed as $channel) {
// TODO: $this->user->notify(new FuelPriceAlert($this->triggerType, $this->fuelType, $this->price));
$this->log($channel, sent: true); $this->log($channel, sent: true);
} }

View File

@@ -2,16 +2,26 @@
namespace App\Jobs; namespace App\Jobs;
use App\Events\PricesUpdatedEvent;
use App\Services\FuelPriceService;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Artisan;
/**
* Background full station refresh + price poll, dispatched from the admin
* "Trigger Full Poll" button. Mirrors the `fuel:poll --full` command but
* calls the service directly so typed exceptions surface to the queue's
* failed-job handler instead of being swallowed by Artisan output buffering.
*/
class PollFuelPricesJob implements ShouldQueue class PollFuelPricesJob implements ShouldQueue
{ {
use Queueable; use Queueable;
public function handle(): void public function handle(FuelPriceService $service): void
{ {
Artisan::call('fuel:poll', ['--full' => true]); $service->refreshStations();
$inserted = $service->pollPrices();
PricesUpdatedEvent::dispatch($inserted, true);
} }
} }

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Jobs;
use App\Mail\PaymentFailedDay3Reminder;
use App\Mail\PaymentFailedDay5Reminder;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Mail;
use InvalidArgumentException;
final class SendPaymentFailedReminderJob implements ShouldQueue
{
use Queueable;
public function __construct(
public readonly int $userId,
public readonly int $day,
) {
$this->onQueue('notifications');
}
public function handle(): void
{
$user = User::find($this->userId);
if ($user === null) {
return;
}
if ($user->grace_period_until === null) {
return;
}
$mailable = match ($this->day) {
3 => new PaymentFailedDay3Reminder($user),
5 => new PaymentFailedDay5Reminder($user),
default => throw new InvalidArgumentException("Unsupported reminder day: {$this->day}"),
};
Mail::to($user->email)->send($mailable);
}
}

View File

@@ -2,16 +2,18 @@
namespace App\Jobs; namespace App\Jobs;
use App\Models\Plan;
use App\Models\User; use App\Models\User;
use App\Models\UserNotificationPreference; use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures; use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable; use Illuminate\Foundation\Queue\Queueable;
/** /**
* Fan-out job for scheduled WhatsApp updates (morning / evening). * Fan-out job for scheduled WhatsApp updates (morning / evening).
* Finds all eligible users and dispatches DispatchUserNotificationJob per user. * Dispatches one DispatchUserNotificationJob per eligible user so each
* user's send is its own queueable unit (independent retry, no shared
* failure mode across the cohort).
* *
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php. * Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/ */
@@ -28,37 +30,24 @@ final class SendScheduledWhatsAppJob implements ShouldQueue
{ {
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening'; $triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Plans that allow scheduled WhatsApp updates // Candidates: users who have explicitly opted in to WhatsApp.
$eligiblePlanNames = Plan::where('active', true) // Per-user tier + daily-limit + scheduled-updates checks happen via
->get() // canSendNow('whatsapp'); that single call covers tier eligibility
->filter(fn (Plan $plan): bool => ($plan->features['whatsapp']['scheduled_updates'] ?? 0) > 0) // (canUseChannel) AND today's notification_log count.
->pluck('name')
->all();
if (empty($eligiblePlanNames)) {
return;
}
// Users who have whatsapp preference enabled
$userIds = UserNotificationPreference::where('channel', 'whatsapp') $userIds = UserNotificationPreference::where('channel', 'whatsapp')
->where('enabled', true) ->where('enabled', true)
->distinct() ->distinct()
->pluck('user_id'); ->pluck('user_id');
User::whereIn('id', $userIds) User::whereIn('id', $userIds)
->each(function (User $user) use ($triggerType, $eligiblePlanNames): void { ->chunkById(500, function (Collection $users) use ($triggerType): void {
$features = PlanFeatures::for($user); foreach ($users as $user) {
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
continue;
}
// Skip if their tier isn't eligible or daily limit is hit DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
if (! in_array($features->tier(), $eligiblePlanNames, strict: true)) {
return;
} }
if (! $features->canSendNow('whatsapp')) {
return;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
}); });
} }
} }

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Listeners;
use App\Jobs\SendPaymentFailedReminderJob;
use App\Models\Subscription;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Laravel\Cashier\Events\WebhookReceived;
final class HandleStripeWebhook
{
public function handle(WebhookReceived $event): void
{
$type = $event->payload['type'] ?? null;
$stripeCustomerId = $event->payload['data']['object']['customer'] ?? null;
if ($stripeCustomerId === null) {
return;
}
$user = User::where('stripe_id', $stripeCustomerId)->first();
if ($user === null) {
return;
}
match ($type) {
'customer.subscription.created',
'customer.subscription.updated' => $this->handleSubscriptionUpserted($user, $event->payload['data']['object'] ?? []),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($user, $event->payload['data']['object'] ?? []),
'invoice.payment_succeeded' => $this->handlePaymentSucceeded($user),
'invoice.payment_failed' => $this->handlePaymentFailed($user),
default => null,
};
}
/**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionUpserted(User $user, array $stripeSubscription): void
{
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user);
}
/**
* @param array<string, mixed> $stripeSubscription
*/
private function handleSubscriptionDeleted(User $user, array $stripeSubscription): void
{
UserNotificationPreference::query()
->where('user_id', $user->id)
->whereIn('channel', ['whatsapp', 'sms'])
->update(['enabled' => false]);
$user->forceFill(['grace_period_until' => null])->save();
$this->syncPeriodFromStripePayload($stripeSubscription);
$this->bustPlanCache($user);
}
/**
* Mirror current_period_start / current_period_end from a Stripe subscription
* payload onto our local row so we don't depend on Stripe at read time.
*
* Stripe API 2024-11-19 places the period fields at the root of the
* subscription; later versions move them to items.data[0]. We accept either.
*
* @param array<string, mixed> $stripeSubscription
*/
private function syncPeriodFromStripePayload(array $stripeSubscription): void
{
$stripeId = $stripeSubscription['id'] ?? null;
if ($stripeId === null) {
return;
}
$subscription = Subscription::where('stripe_id', $stripeId)->first();
if ($subscription === null) {
return;
}
$start = $stripeSubscription['current_period_start']
?? ($stripeSubscription['items']['data'][0]['current_period_start'] ?? null);
$end = $stripeSubscription['current_period_end']
?? ($stripeSubscription['items']['data'][0]['current_period_end'] ?? null);
$subscription->stripe_data = $stripeSubscription;
if ($start !== null) {
$subscription->current_period_start = Carbon::createFromTimestamp($start);
}
if ($end !== null) {
$subscription->current_period_end = Carbon::createFromTimestamp($end);
}
$subscription->save();
}
private function handlePaymentSucceeded(User $user): void
{
$user->forceFill(['grace_period_until' => null])->save();
$this->bustPlanCache($user);
}
private function handlePaymentFailed(User $user): void
{
// Idempotency: only the first failed-payment event in a grace window
// transitions state + dispatches reminders. Stripe may fire this event
// multiple times per billing cycle (once per failed retry attempt).
if ($user->grace_period_until !== null) {
return;
}
$user->forceFill(['grace_period_until' => Carbon::now()->addDays(5)])->save();
SendPaymentFailedReminderJob::dispatch($user->id, 3)->delay(Carbon::now()->addDays(3));
SendPaymentFailedReminderJob::dispatch($user->id, 5)->delay(Carbon::now()->addDays(5));
$this->bustPlanCache($user);
}
private function bustPlanCache(User $user): void
{
$tag = Cache::tags(['plans']);
$tag->forget("plan_for_user_{$user->id}");
$tag->forget("plan_cadence_for_user_{$user->id}");
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay3Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Heads up — your FuelAlert payment is retrying',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payment-failed-day-3',
with: [
'name' => $this->user->name,
'tier' => PlanFeatures::for($this->user)->tier(),
'portalUrl' => route('billing.portal'),
],
);
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use App\Models\User;
use App\Services\PlanFeatures;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
final class PaymentFailedDay5Reminder extends Mailable
{
public function __construct(public readonly User $user) {}
public function envelope(): Envelope
{
$tier = PlanFeatures::for($this->user)->tier();
return new Envelope(
subject: 'Last chance — your '.ucfirst($tier).' features end tomorrow',
);
}
public function content(): Content
{
return new Content(
markdown: 'emails.payment-failed-day-5',
with: [
'name' => $this->user->name,
'tier' => PlanFeatures::for($this->user)->tier(),
'portalUrl' => route('billing.portal'),
],
);
}
}

View File

@@ -7,7 +7,21 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
#[Fillable(['service', 'method', 'url', 'status_code', 'duration_ms', 'error'])] #[Fillable([
'service',
'method',
'url',
'status_code',
'duration_ms',
'error',
'response_body',
'input_tokens',
'output_tokens',
'cache_read_tokens',
'cache_write_tokens',
'ratelimit_remaining',
'ratelimit_reset_at',
])]
class ApiLog extends Model class ApiLog extends Model
{ {
/** @use HasFactory<ApiLogFactory> */ /** @use HasFactory<ApiLogFactory> */
@@ -19,6 +33,7 @@ class ApiLog extends Model
{ {
return [ return [
'created_at' => 'datetime', 'created_at' => 'datetime',
'ratelimit_reset_at' => 'datetime',
]; ];
} }
} }

45
app/Models/Backtest.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Database\Factories\BacktestFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'model_version',
'features_json',
'coefficients_json',
'train_start',
'train_end',
'eval_start',
'eval_end',
'directional_accuracy',
'mae_pence',
'calibration_table',
'leak_suspected',
'ran_at',
])]
class Backtest extends Model
{
/** @use HasFactory<BacktestFactory> */
use HasFactory;
protected function casts(): array
{
return [
'features_json' => 'array',
'coefficients_json' => 'array',
'calibration_table' => 'array',
'train_start' => 'date',
'train_end' => 'date',
'eval_start' => 'date',
'eval_end' => 'date',
'directional_accuracy' => 'decimal:2',
'mae_pence' => 'decimal:2',
'leak_suspected' => 'boolean',
'ran_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'forecast_for',
'model_version',
'predicted_class',
'actual_class',
'correct',
'abs_error_pence',
'resolved_at',
])]
class ForecastOutcome extends Model
{
public $timestamps = false;
public $incrementing = false;
protected $primaryKey = 'forecast_for';
protected $keyType = 'string';
protected function casts(): array
{
return [
'forecast_for' => 'date',
'correct' => 'boolean',
'abs_error_pence' => 'integer',
'resolved_at' => 'datetime',
];
}
}

35
app/Models/LlmOverlay.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'ran_at',
'forecast_for_week',
'direction',
'confidence',
'reasoning',
'events_json',
'agrees_with_ridge',
'major_impact_event',
'volatility_flag_on',
'search_used',
])]
class LlmOverlay extends Model
{
protected function casts(): array
{
return [
'ran_at' => 'datetime',
'forecast_for_week' => 'date',
'confidence' => 'integer',
'events_json' => 'array',
'agrees_with_ridge' => 'boolean',
'major_impact_event' => 'boolean',
'volatility_flag_on' => 'boolean',
'search_used' => 'boolean',
];
}
}

26
app/Models/Outcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['outcode', 'lat', 'lng'])]
class Outcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'outcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

View File

@@ -15,8 +15,21 @@ class Plan extends Model
protected $fillable = [ protected $fillable = [
'name', 'name',
'stripe_price_id', 'stripe_price_id_monthly',
'features', 'stripe_price_id_annual',
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
'active', 'active',
]; ];
@@ -28,10 +41,10 @@ class Plan extends Model
{ {
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store(); $cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
return $cache->remember( $planId = $cache->remember(
"plan_for_user_{$user->id}", "plan_for_user_{$user->id}",
3600, 3600,
function () use ($user): self { function () use ($user): ?int {
$priceId = null; $priceId = null;
if (method_exists($user, 'subscriptions')) { if (method_exists($user, 'subscriptions')) {
@@ -40,14 +53,63 @@ class Plan extends Model
} }
if ($priceId) { if ($priceId) {
$plan = static::where('stripe_price_id', $priceId)->where('active', true)->first(); $plan = static::where(fn ($q) => $q
->where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId))
->where('active', true)
->first();
if ($plan) { if ($plan) {
return $plan; return $plan->id;
} }
} }
return static::where('name', PlanTier::Free->value)->firstOrFail(); return static::where('name', PlanTier::Free->value)->value('id');
}
);
return static::findOrFail($planId);
}
/**
* Resolve the active subscription cadence for a user.
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
*/
public static function resolveCadenceForUser(User $user): ?string
{
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
return $cache->remember(
"plan_cadence_for_user_{$user->id}",
3600,
function () use ($user): ?string {
if (! method_exists($user, 'subscriptions')) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
if ($priceId === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
} }
); );
} }
@@ -64,8 +126,26 @@ class Plan extends Model
protected function casts(): array protected function casts(): array
{ {
return [ return [
'features' => 'array', 'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => 'boolean', 'active' => 'boolean',
]; ];
} }
/** User-facing display label for this plan (e.g. basic → "Daily"). */
public function displayName(): string
{
$tier = PlanTier::tryFrom((string) $this->name) ?? PlanTier::Free;
return $tier->label();
}
} }

26
app/Models/Postcode.php Normal file
View File

@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['postcode', 'outcode', 'lat', 'lng'])]
class Postcode extends Model
{
public $timestamps = false;
protected $primaryKey = 'postcode';
public $incrementing = false;
protected $keyType = 'string';
protected function casts(): array
{
return [
'lat' => 'float',
'lng' => 'float',
];
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Models;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use Database\Factories\PricePredictionFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model
{
/** @use HasFactory<PricePredictionFactory> */
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'predicted_for' => 'date',
'source' => PredictionSource::class,
'direction' => TrendDirection::class,
'confidence' => 'integer',
'generated_at' => 'datetime',
];
}
/**
* Order by source quality: llm_with_context llm ewma.
* Use this whenever reading the "best" prediction for a given date.
*
* @param Builder<PricePrediction> $query
* @return Builder<PricePrediction>
*/
public function scopeBestFirst(Builder $query): Builder
{
$priority = implode(', ', array_map(
fn (string $v) => "'$v'",
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value],
));
return $query->orderByRaw("FIELD(source, $priority)");
}
}

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

View File

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

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

@@ -13,15 +13,16 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Laravel\Cashier\Billable;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password', 'is_admin', 'postcode', 'preferred_fuel_type'])] #[Fillable(['name', 'email', 'email_verified_at', 'password', 'is_admin', 'postcode', 'preferred_fuel_type', 'grace_period_until'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements FilamentUser class User extends Authenticatable implements FilamentUser
{ {
/** @use HasFactory<UserFactory> */ /** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; use Billable, HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable;
/** /**
* Get the attributes that should be cast. * Get the attributes that should be cast.
@@ -34,6 +35,7 @@ class User extends Authenticatable implements FilamentUser
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'is_admin' => 'boolean', 'is_admin' => 'boolean',
'grace_period_until' => 'datetime',
]; ];
} }

View File

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

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'flipped_on_at',
'flipped_off_at',
'trigger',
'trigger_detail',
'active',
])]
class VolatilityRegime extends Model
{
protected function casts(): array
{
return [
'flipped_on_at' => 'datetime',
'flipped_off_at' => 'datetime',
'active' => 'boolean',
];
}
public static function currentlyActive(): ?self
{
return static::query()->where('active', true)->orderByDesc('flipped_on_at')->first();
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Database\Factories\WatchedEventFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'label',
'starts_at',
'ends_at',
'notes',
])]
class WatchedEvent extends Model
{
/** @use HasFactory<WatchedEventFactory> */
use HasFactory;
protected function casts(): array
{
return [
'starts_at' => 'datetime',
'ends_at' => 'datetime',
];
}
}

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Database\Factories\WeeklyForecastFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable([
'forecast_for',
'model_version',
'direction',
'magnitude_pence',
'ridge_confidence',
'flagged_duty_change',
'reasoning',
'generated_at',
])]
class WeeklyForecast extends Model
{
/** @use HasFactory<WeeklyForecastFactory> */
use HasFactory;
protected function casts(): array
{
return [
'forecast_for' => 'date',
'magnitude_pence' => 'integer',
'ridge_confidence' => 'integer',
'flagged_duty_change' => 'boolean',
'generated_at' => 'datetime',
];
}
}

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

@@ -2,16 +2,16 @@
namespace App\Providers; namespace App\Providers;
use App\Services\ApiLogger; use App\Listeners\HandleStripeWebhook;
use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Models\Subscription;
use App\Services\LlmPrediction\GeminiPredictionProvider;
use App\Services\LlmPrediction\OilPredictionProvider;
use App\Services\LlmPrediction\OpenAiPredictionProvider;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -20,15 +20,9 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
$this->app->bind(OilPredictionProvider::class, function ($app) { // No bindings here. The legacy LLM prediction provider binding
$logger = $app->make(ApiLogger::class); // was removed when the Phase 4 ridge model + Phase 8
// LlmOverlayService replaced the old daily oil prediction.
return match (config('services.llm.provider')) {
'openai' => new OpenAiPredictionProvider($logger),
'gemini' => new GeminiPredictionProvider($logger),
default => new AnthropicPredictionProvider($logger),
};
});
} }
/** /**
@@ -37,6 +31,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
$this->configureDefaults(); $this->configureDefaults();
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
} }
/** /**
@@ -50,13 +48,6 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(), app()->isProduction(),
); );
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
if (DB::connection()->getDriverName() === 'sqlite') {
$pdo = DB::connection()->getPdo();
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
}
Password::defaults(fn (): ?Password => app()->isProduction() Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12) ? Password::min(12)
->mixedCase() ->mixedCase()

View File

@@ -3,17 +3,29 @@
namespace App\Services; namespace App\Services;
use App\Models\ApiLog; use App\Models\ApiLog;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Throwable; use Throwable;
class ApiLogger class ApiLogger
{ {
/**
* Cap the stored response body. MEDIUMTEXT can hold ~16MB, but
* persisting more than 64KB is rarely useful for debugging and
* blows up the row size on busy services.
*/
private const int RESPONSE_BODY_CAP = 65_536;
/** /**
* Execute an HTTP request and log it to api_logs. * Execute an HTTP request and log it to api_logs.
* *
* The callable must return an Illuminate\Http\Client\Response. * The callable must return an Illuminate\Http\Client\Response.
* Exceptions are logged and re-thrown so the caller handles them. * Exceptions are logged and re-thrown so the caller handles them.
* *
* Persists the response body to `api_logs.response_body` ONLY when
* the call failed (non-2xx) or threw. Truncates to RESPONSE_BODY_CAP.
*
* @param callable(): Response $request * @param callable(): Response $request
*/ */
public function send(string $service, string $method, string $url, callable $request): Response public function send(string $service, string $method, string $url, callable $request): Response
@@ -21,15 +33,31 @@ class ApiLogger
$start = microtime(true); $start = microtime(true);
$statusCode = null; $statusCode = null;
$error = null; $error = null;
$responseBody = null;
$usage = [];
try { try {
$response = $request(); $response = $request();
$statusCode = $response->status(); $statusCode = $response->status();
$usage = $this->extractUsage($response);
if ($response->failed()) {
$body = $response->body();
$error = Str::limit($body, 1000);
$responseBody = $this->truncate($body);
}
return $response; return $response;
} catch (Throwable $e) { } catch (Throwable $e) {
$error = $e->getMessage(); $error = $e->getMessage();
// RequestException carries the response, ConnectionException
// doesn't. Pull the body when it's available.
if ($e instanceof RequestException) {
$responseBody = $this->truncate($e->response->body());
$usage = $this->extractUsage($e->response);
}
throw $e; throw $e;
} finally { } finally {
ApiLog::create([ ApiLog::create([
@@ -39,7 +67,51 @@ class ApiLogger
'status_code' => $statusCode, 'status_code' => $statusCode,
'duration_ms' => (int) round((microtime(true) - $start) * 1000), 'duration_ms' => (int) round((microtime(true) - $start) * 1000),
'error' => $error, 'error' => $error,
'response_body' => $responseBody,
...$usage,
]); ]);
} }
} }
private function truncate(string $body): string
{
return strlen($body) > self::RESPONSE_BODY_CAP
? substr($body, 0, self::RESPONSE_BODY_CAP)
: $body;
}
/**
* Pull token-usage and rate-limit telemetry from a provider response.
*
* Today only Anthropic exposes both. Other providers return mostly
* NULLs callers don't need to know which is which.
*
* @return array<string, int|string|null>
*/
private function extractUsage(?Response $response): array
{
if ($response === null) {
return [];
}
$usage = $response->json('usage');
$tokens = is_array($usage) ? $usage : [];
$reset = $response->header('anthropic-ratelimit-input-tokens-reset');
$remaining = $response->header('anthropic-ratelimit-input-tokens-remaining');
return [
'input_tokens' => $this->intOrNull($tokens['input_tokens'] ?? null),
'output_tokens' => $this->intOrNull($tokens['output_tokens'] ?? null),
'cache_read_tokens' => $this->intOrNull($tokens['cache_read_input_tokens'] ?? null),
'cache_write_tokens' => $this->intOrNull($tokens['cache_creation_input_tokens'] ?? null),
'ratelimit_remaining' => $this->intOrNull($remaining !== '' ? $remaining : null),
'ratelimit_reset_at' => $reset !== '' ? $reset : null,
];
}
private function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
} }

View File

@@ -41,4 +41,24 @@ final readonly class BrentPriceFetcher
BrentPrice::upsert($rows, ['date'], ['price_usd']); BrentPrice::upsert($rows, ['date'], ['price_usd']);
} }
/**
* One-shot Brent backfill via FRED's observation_start/end. Used to
* seed `brent_prices` going back to 2018 so Phase 9's volatility
* detector and Phase 8's LLM overlay have proper context.
*
* @return int rows inserted/updated
*/
public function backfillFromFred(string $from, string $to): int
{
$rows = $this->fred->fetchRange($from, $to);
if ($rows === null) {
throw new BrentPriceFetchException("FRED backfill ({$from}{$to}) returned no data");
}
BrentPrice::upsert($rows, ['date'], ['price_usd']);
return count($rows);
}
} }

View File

@@ -1,135 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_ALPHA = 0.3;
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly OilPredictionProvider $provider,
) {}
/**
* Return the latest BrentPrice row, or null if none exists.
*/
public function latestPrice(): ?BrentPrice
{
return BrentPrice::orderBy('date', 'desc')->first();
}
/**
* Generate EWMA + LLM predictions, store them, and flag the latest
* brent_prices row as having a prediction generated.
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('BrentPricePredictor: not enough price data', [
'rows' => $prices->count(),
]);
return null;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
}
$result = $llm ?? $ewma;
if ($result !== null) {
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $result;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
/**
* @param float[] $prices Chronological (oldest first).
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Services\BrentPriceSources; namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
final class EiaBrentPriceSource final class EiaBrentPriceSource
@@ -14,12 +15,16 @@ final class EiaBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {} public function __construct(private readonly ApiLogger $apiLogger) {}
/** /**
* @return array{date: string, price_usd: float}[]|null * @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/ */
public function fetch(): ?array public function fetch(): ?array
{ {
try { try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(10) $response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [ ->get(self::URL, [
'api_key' => config('services.eia.api_key'), 'api_key' => config('services.eia.api_key'),
'frequency' => 'daily', 'frequency' => 'daily',
@@ -29,32 +34,26 @@ final class EiaBrentPriceSource
'sort[0][direction]' => 'desc', 'sort[0][direction]' => 'desc',
'length' => 30, 'length' => 30,
])); ]));
} catch (ConnectionException $e) {
if (! $response->successful()) { throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
Log::error('EiaBrentPriceSource: request failed', ['status' => $response->status()]); } catch (RequestException $e) {
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
return null;
}
$rows = collect($response->json('response.data') ?? [])
->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;
} }
$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; namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
final class FredBrentPriceSource final class FredBrentPriceSource
@@ -14,45 +15,76 @@ final class FredBrentPriceSource
public function __construct(private readonly ApiLogger $apiLogger) {} public function __construct(private readonly ApiLogger $apiLogger) {}
/** /**
* @return array{date: string, price_usd: float}[]|null * @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/ */
public function fetch(): ?array public function fetch(): ?array
{ {
return $this->call([
'sort_order' => 'desc',
'limit' => 30,
]);
}
/**
* Backfill range (inclusive). FRED's `observation_start` /
* `observation_end` parameters expect ISO dates (YYYY-MM-DD).
* Returns null when the range is empty (e.g. all weekends/holidays).
*
* @return array{date: string, price_usd: float}[]|null
*
* @throws BrentPriceFetchException
*/
public function fetchRange(string $from, string $to): ?array
{
return $this->call([
'observation_start' => $from,
'observation_end' => $to,
'sort_order' => 'asc',
'limit' => 100000,
]);
}
/**
* @param array<string, scalar> $extraParams
* @return array{date: string, price_usd: float}[]|null
*
* @throws BrentPriceFetchException
*/
private function call(array $extraParams): ?array
{
$params = array_merge([
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'file_type' => 'json',
], $extraParams);
try { try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(10) $response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(60)
->get(self::URL, [ ->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
'series_id' => 'DCOILBRENTEU', ->throw()
'api_key' => config('services.fred.api_key'), ->get(self::URL, $params));
'sort_order' => 'desc', } catch (ConnectionException $e) {
'limit' => 30, throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
'file_type' => 'json', } catch (RequestException $e) {
])); throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
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;
} }
$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());
} }
} }

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Services\Forecasting;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/**
* Trailing-13-week hit rate for a model_version. Read from
* `forecast_outcomes`. Returns null when fewer than 4 outcomes are
* available (a single bad week would otherwise dominate the ratio).
*/
final class AccuracyHistory
{
private const int WEEKS = 13;
private const int MIN_OUTCOMES = 4;
public function trailingHitRate(string $modelVersion): ?float
{
$cutoff = Carbon::now()->subWeeks(self::WEEKS)->toDateString();
$row = DB::table('forecast_outcomes')
->where('model_version', $modelVersion)
->where('forecast_for', '>=', $cutoff)
->selectRaw('COUNT(*) as total, SUM(CASE WHEN correct THEN 1 ELSE 0 END) as correct')
->first();
$total = (int) ($row->total ?? 0);
if ($total < self::MIN_OUTCOMES) {
return null;
}
return (int) ($row->correct ?? 0) / $total;
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Services\Forecasting;
use App\Models\Backtest;
use App\Services\Forecasting\Contracts\WeeklyForecastModel;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Runs a WeeklyForecastModel through a train/eval split and persists
* the result to the `backtests` table.
*
* Pipeline:
* 1. Generate the training and eval Monday lists from the date ranges.
* 2. Run LeakDetector against every Monday × every feature. Refuse to
* train if any source date is on or after a target Monday.
* 3. Train the model.
* 4. For each eval Monday: predict, look up actual ΔULSP from
* `weekly_pump_prices`, score directional accuracy + abs error.
* 5. Persist a Backtest row, return it.
*
* The `leak_suspected` flag is a *secondary* smell test (true when
* directional_accuracy > 75). Primary leak defence is step 2.
*/
final class BacktestRunner
{
private const float FLAT_THRESHOLD_PENCE_X100 = 20.0; // 0.2 p/L
public function __construct(
private readonly LeakDetector $leakDetector = new LeakDetector,
) {}
public function run(
WeeklyForecastModel $model,
CarbonInterface $trainStart,
CarbonInterface $trainEnd,
CarbonInterface $evalStart,
CarbonInterface $evalEnd,
): Backtest {
$trainingMondays = $this->mondaysBetween($trainStart, $trainEnd);
$evalMondays = $this->mondaysBetween($evalStart, $evalEnd);
$spec = $model->featureSpec();
$report = $this->leakDetector->validate($spec, [...$trainingMondays, ...$evalMondays]);
if ($report->hasLeaks()) {
throw new LeakDetectorException($report);
}
$model->train($trainingMondays);
$correct = 0;
$totalScored = 0;
$absErrors = [];
$bins = [];
foreach ($evalMondays as $monday) {
$actualDelta = $this->actualDeltaPence($monday);
if ($actualDelta === null) {
continue;
}
$prediction = $model->predict($monday);
$actualDirection = $this->classifyDirection($actualDelta);
$hit = $prediction->direction === $actualDirection;
$totalScored++;
$absErrors[] = abs($prediction->magnitudePence - $actualDelta);
if ($hit) {
$correct++;
}
$bin = $this->bucketForMagnitude($prediction->magnitudePence);
$bins[$bin] ??= ['correct' => 0, 'total' => 0];
$bins[$bin]['total']++;
if ($hit) {
$bins[$bin]['correct']++;
}
}
$directionalAccuracy = $totalScored === 0
? null
: round(($correct / $totalScored) * 100, 2);
$maePence = $absErrors === []
? null
: round((array_sum($absErrors) / count($absErrors)) / 100, 2);
$calibrationTable = [];
foreach ($bins as $key => $b) {
$calibrationTable[$key] = round($b['correct'] / $b['total'], 4);
}
return Backtest::create([
'model_version' => $spec->modelVersion(),
'features_json' => $spec->toArray(),
'coefficients_json' => $model->coefficients(),
'train_start' => $trainStart->toDateString(),
'train_end' => $trainEnd->toDateString(),
'eval_start' => $evalStart->toDateString(),
'eval_end' => $evalEnd->toDateString(),
'directional_accuracy' => $directionalAccuracy,
'mae_pence' => $maePence,
'calibration_table' => $calibrationTable,
'leak_suspected' => $directionalAccuracy !== null && $directionalAccuracy > 75.0,
'ran_at' => now(),
]);
}
/** @return array<int, CarbonInterface> */
private function mondaysBetween(CarbonInterface $start, CarbonInterface $end): array
{
$mondays = [];
$cursor = $start->copy()->startOfDay();
$boundary = $end->copy()->startOfDay();
while ($cursor->lessThanOrEqualTo($boundary)) {
if ($cursor->dayOfWeek === CarbonInterface::MONDAY) {
$mondays[] = $cursor->copy();
}
$cursor = $cursor->addDay();
}
return $mondays;
}
private function actualDeltaPence(CarbonInterface $targetMonday): ?float
{
$current = DB::table('weekly_pump_prices')
->where('date', $targetMonday->toDateString())
->value('ulsp_pence');
$previous = DB::table('weekly_pump_prices')
->where('date', $targetMonday->copy()->subDays(7)->toDateString())
->value('ulsp_pence');
if ($current === null || $previous === null) {
return null;
}
return (float) ($current - $previous);
}
private function classifyDirection(float $deltaPence): string
{
return match (true) {
$deltaPence > self::FLAT_THRESHOLD_PENCE_X100 => 'rising',
$deltaPence < -self::FLAT_THRESHOLD_PENCE_X100 => 'falling',
default => 'flat',
};
}
private function bucketForMagnitude(float $magnitudePence): string
{
$abs = abs($magnitudePence);
return match (true) {
$abs < 50.0 => '0.0-0.5p',
$abs < 100.0 => '0.5-1.0p',
default => '1.0p+',
};
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Services\Forecasting;
use DateTime;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Pulls the latest "Weekly road fuel prices (CSV) 2018 to 2026"
* attachment from gov.uk's content API and upserts into
* `weekly_pump_prices`.
*
* Idempotent: re-running on a day with no new publication is a no-op
* (rows match by primary key `date`, content is unchanged).
*
* The forecast cache is busted at the end so the next API hit retrains
* the ridge model on the fresh row.
*/
final class BeisImporter
{
private const string API_URL = 'https://www.gov.uk/api/content/government/statistics/weekly-road-fuel-prices';
private const string ATTACHMENT_TITLE = 'Weekly road fuel prices (CSV) 2018 to 2026';
/**
* @return array{
* csv_url: string,
* parsed: int,
* upserted: int,
* latest_date: string,
* }
*/
public function import(): array
{
$url = $this->resolveCsvUrl();
$csv = $this->downloadCsv($url);
$rows = $this->parse($csv);
if ($rows === []) {
throw new RuntimeException('BEIS CSV parsed empty — check delimiter / encoding');
}
DB::table('weekly_pump_prices')->upsert(
$rows,
['date'],
['ulsp_pence', 'ulsd_pence', 'ulsp_duty_pence', 'ulsd_duty_pence', 'ulsp_vat_pct', 'ulsd_vat_pct'],
);
Cache::flush();
$latest = (string) collect($rows)->pluck('date')->sortDesc()->first();
return [
'csv_url' => $url,
'parsed' => count($rows),
'upserted' => count($rows),
'latest_date' => $latest,
];
}
private function resolveCsvUrl(): string
{
$response = Http::timeout(15)->acceptJson()->get(self::API_URL);
$response->throw();
$attachments = $response->json('details.attachments', []);
foreach ($attachments as $a) {
if (($a['title'] ?? null) === self::ATTACHMENT_TITLE) {
$url = $a['url'] ?? null;
if (! is_string($url) || $url === '') {
throw new RuntimeException('BEIS attachment had empty URL');
}
return $url;
}
}
throw new RuntimeException(sprintf(
'gov.uk content API did not return an attachment titled %s',
self::ATTACHMENT_TITLE,
));
}
private function downloadCsv(string $url): string
{
$response = Http::timeout(60)->get($url);
$response->throw();
return $response->body();
}
/**
* @return array<int, array<string, int|string>>
*/
private function parse(string $csv): array
{
$rows = [];
$lines = preg_split('/\r\n|\r|\n/', $csv);
if ($lines === false || count($lines) < 2) {
return [];
}
// Skip header.
array_shift($lines);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$cols = str_getcsv($line, escape: '\\');
if (count($cols) < 7) {
continue;
}
$date = DateTime::createFromFormat('d/m/Y', trim($cols[0]));
if ($date === false) {
continue;
}
$rows[] = [
'date' => $date->format('Y-m-d'),
'ulsp_pence' => (int) round(((float) $cols[1]) * 100),
'ulsd_pence' => (int) round(((float) $cols[2]) * 100),
'ulsp_duty_pence' => (int) round(((float) $cols[3]) * 100),
'ulsd_duty_pence' => (int) round(((float) $cols[4]) * 100),
'ulsp_vat_pct' => (int) $cols[5],
'ulsd_vat_pct' => (int) $cols[6],
];
}
return $rows;
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Services\Forecasting\Contracts;
use Carbon\CarbonInterface;
/**
* A single feature in a weekly forecast model.
*
* Implementations must be deterministic for a given target Monday and
* must declare every source date they read so the LeakDetector can
* verify no source date is on or after the target Monday.
*/
interface ForecastFeature
{
public function name(): string;
/**
* Feature value at $targetMonday, or null when an upstream data
* row is missing. Caller is expected to drop the entire feature
* vector when any single feature is null.
*/
public function valueFor(CarbonInterface $targetMonday): ?float;
/**
* Every date this feature reads from any data source for a given
* target Monday. The LeakDetector requires every returned date to
* be strictly before $targetMonday.
*
* @return array<int, CarbonInterface>
*/
public function sourceDates(CarbonInterface $targetMonday): array;
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Forecasting\Contracts;
use App\Services\Forecasting\FeatureSpec;
use App\Services\Forecasting\WeeklyPrediction;
use Carbon\CarbonInterface;
/**
* Contract every weekly forecaster must satisfy. The harness consumes
* this interface naive baselines, ridge regression, and any future
* model all implement it.
*/
interface WeeklyForecastModel
{
public function featureSpec(): FeatureSpec;
/**
* Train on the supplied weeks. Implementations may store coefficients
* internally for the subsequent predict() calls.
*
* @param array<int, CarbonInterface> $trainingMondays
*/
public function train(array $trainingMondays): void;
/**
* Predict ΔULSP for the week starting $targetMonday. Returned value
* is in pence × 100 (integer-ish, but typed float for fractional
* predictions).
*/
public function predict(CarbonInterface $targetMonday): WeeklyPrediction;
/**
* Coefficients in a JSON-serialisable form, or null for non-parametric
* models like the naive baseline.
*
* @return array<string, mixed>|null
*/
public function coefficients(): ?array;
}

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Services\Forecasting;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB;
/**
* Flags forecast weeks that fall within ±4 weeks of a known UK fuel
* duty change. Per the spec calibration override (n=1), the displayed
* confidence on flagged weeks is halved and the reasoning text says so.
*/
final class DutyChangeDetector
{
public const int FLAG_RADIUS_WEEKS = 4;
/**
* Returns true if the target Monday is within ±4 weeks of any
* change in `weekly_pump_prices.ulsp_duty_pence`.
*/
public function isAdjacent(CarbonInterface $targetMonday): bool
{
$start = $targetMonday->copy()->subWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
$end = $targetMonday->copy()->addWeeks(self::FLAG_RADIUS_WEEKS)->toDateString();
$rows = DB::table('weekly_pump_prices')
->whereBetween('date', [$start, $end])
->orderBy('date')
->get(['date', 'ulsp_duty_pence']);
if ($rows->count() < 2) {
return false;
}
$previous = null;
foreach ($rows as $r) {
if ($previous !== null && (int) $r->ulsp_duty_pence !== $previous) {
return true;
}
$previous = (int) $r->ulsp_duty_pence;
}
return false;
}
}

Some files were not shown because too many files have changed in this diff Show More