Compare commits

..

85 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Service: 803 → 414 lines.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:21 +01:00
Ovidiu U
b8adb81c79 chore: gitignore ONSPD source CSV
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 09:29:07 +01:00
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
Ovidiu U
aec547cd86 refactor: restructure Stripe pricing config to support monthly and annual 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
- Nest price IDs under `monthly` and `annual` keys for each tier (basic, plus, pro)
2026-04-14 19:26:01 +01:00
Ovidiu U
486f0e689c refactor: split oil price ingestion and prediction into separate services + commands
- BrentPriceFetcher owns ingestion (fetchFromEia / fetchFromFred, each throws on failure)
- BrentPricePredictor owns prediction and marks latest brent_prices row as generated
- oil:fetch command tries EIA, falls back to FRED, fails loudly if both fail
- oil:predict command prompts if latest price already has a prediction; --force bypasses
- add prediction_generated_at column to brent_prices
- delete OilPriceService (replaced by the two focused services)
2026-04-14 16:59:43 +01:00
Ovidiu U
1a0381265e refactor: extract Brent price sources into dedicated classes
OilPriceService no longer inlines per-provider fetch/transform/error logic.
EIA and FRED are now their own classes with a common shape; the service
just iterates and upserts the first successful result.
2026-04-14 16:29:52 +01:00
Ovidiu U
a7ee9f4557 feat: use EIA as primary Brent crude source with FRED fallback 2026-04-14 16:23:06 +01:00
Ovidiu U
4220b1b86a Add subscription tiers, notification preferences, and logging infrastructure
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 database migrations for plans, subscriptions, notification preferences, and notification log tables
- Implement DispatchUserNotificationJob to handle channel resolution, daily limits, and logging (sent/tier_restricted/daily_limit)
- Add SendScheduledWhatsAppJob for scheduled notification delivery
- Create PlanFeatures service to resolve tier capabilities, check daily limits, and validate fuel
2026-04-14 16:20:51 +01:00
Ovidiu U
3cd3467178 config: add EIA API key for Brent crude price source 2026-04-14 16:20:07 +01:00
Ovidiu U
d25883ead4 feat: add geolocation support with Near Me button and user location marker on map
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 "Near Me" button to SearchBar with loading state and geolocation via postcodes.io API
- Display user location on map with pulsing blue marker using geolocation API with IP fallback
- Adjust map zoom level based on search radius for better context
- Pass radiusMiles prop from
2026-04-11 21:27:11 +01:00
Ovidiu U
a969c1b347 feat: add fuel price classification markers and responsive search UI improvements
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 map pin icon to right side of input with adjusted spacing
- Change button styling from accent to primary color
2026-04-11 20:51:07 +01:00
Ovidiu U
951bb0b98d feat: add sort select to homepage SearchBar 2026-04-11 18:57:03 +01:00
Ovidiu U
b8adc98669 feat: add brand and reliable sort options to StationList 2026-04-11 18:55:21 +01:00
Ovidiu U
2747047f53 fix: correct default fuelType in useStations from 'petrol' to 'e10' 2026-04-11 18:50:29 +01:00
Ovidiu U
5fe9f9dc6d fix: empty results state and validation error display on homepage 2026-04-11 18:48:25 +01:00
Ovidiu U
276f9bf612 feat: wire up homepage search with map and station list 2026-04-11 18:46:34 +01:00
Ovidiu U
6f52f3f0d7 feat: add defaultOpen prop to LeafletMap 2026-04-11 18:45:26 +01:00
Ovidiu U
d11d500a35 fix: accessibility and Enter key handling in SearchBar 2026-04-11 18:44:34 +01:00
Ovidiu U
b5ee25db67 feat: add fuel type and radius selects to SearchBar 2026-04-11 17:23:56 +01:00
Ovidiu U
66c662f471 docs: add homepage search implementation plan 2026-04-11 17:23:03 +01:00
Ovidiu U
9f7b45751e docs: add homepage search design spec 2026-04-11 17:15:23 +01:00
213 changed files with 15449 additions and 2583 deletions

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"
---

409
.claude/rules/tiers.md Normal file
View File

@@ -0,0 +1,409 @@
# Tier & Entitlement System — Claude Code Rules
## Overview
FuelAlert uses a plan-based entitlement system. Every decision about what a user
can receive, on which channel, at what frequency, is resolved through a single
`PlanFeatures` service. Nothing else makes entitlement decisions.
Users subscribe via Stripe (Laravel Cashier). The active plan is resolved from
the Stripe subscription's price ID, mapped to a `Plan` model row.
---
## Tiers
| Tier | Price | Stripe Price Env Key |
|-------|--------|-------------------------------|
| free | £0 | — |
| basic | £0.99 | `STRIPE_PRICE_BASIC` |
| plus | £2.49 | `STRIPE_PRICE_PLUS` |
| pro | £3.99 | `STRIPE_PRICE_PRO` |
A user with no active Cashier subscription is always resolved as `free`.
---
## Fuel Types
Six fuel types exist across the app:
```
E10, E5, B7_STANDARD, B7_PREMIUM, B10, HVO
```
All six are available to all tiers. The restriction is quantity only:
| Tier | Max tracked fuel types |
|---------------|------------------------|
| free | 1 |
| basic | 1 |
| plus | 1 |
| pro | unlimited (null) |
---
## Notification Channels
| Channel | free | basic | plus | pro |
|-----------|---------------|--------------|--------------|--------------|
| email | weekly digest | daily | ✓ triggered | ✓ triggered |
| push | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| whatsapp | ✗ | ✓ daily | ✓ triggered | ✓ triggered |
| sms | ✗ | ✗ | ✓ max 1/day | ✓ max 3/day |
WhatsApp also supports scheduled updates (morning + evening) independent of
price triggers — available to any tier that has WhatsApp enabled.
Channel daily limits (`sms_daily_limit`, `whatsapp_daily_limit`) are enforced
by counting rows in `notification_log` for `(user_id, channel, DATE(created_at))`.
---
## Notification Triggers
| Trigger | Description |
|----------------------|----------------------------------------------------------|
| `price_threshold` | Price drops at or below the user's saved threshold |
| `score_change` | Fill-up score flips good↔bad for a fuel type |
| `scheduled_morning` | WhatsApp scheduled update — fired by scheduler |
| `scheduled_evening` | WhatsApp scheduled update — fired by scheduler |
---
## Enum
Use a `PlanTier` backed enum at `app/Enums/PlanTier.php`:
```php
enum PlanTier: string
{
case Free = 'free';
case Basic = 'basic';
case Plus = 'plus';
case Pro = 'pro';
}
```
Reference `PlanTier::Free->value` everywhere, never raw strings.
## Stripe Price IDs
Each tier has two prices:
| Tier | Monthly Env Key | Annual Env Key |
|-------|--------------------------------|-------------------------------|
| basic | `STRIPE_PRICE_BASIC_MONTHLY` | `STRIPE_PRICE_BASIC_ANNUAL` |
| plus | `STRIPE_PRICE_PLUS_MONTHLY` | `STRIPE_PRICE_PLUS_ANNUAL` |
| pro | `STRIPE_PRICE_PRO_MONTHLY` | `STRIPE_PRICE_PRO_ANNUAL` |
## Database Schema
### `plans` table
```
id unsignedBigInteger PK
name string — free | basic | plus | pro
stripe_price_id_monthly string nullable — Cashier price ID for monthly billing
stripe_price_id_annual string nullable — Cashier price ID for annual billing
features json — see shape below
active boolean default true
timestamps
```
**`features` JSON shape:**
```json
{
"fuel_types": {
"max": 1
},
"email": {
"enabled": true,
"frequency": "triggered"
},
"push": {
"enabled": true,
"frequency": "triggered"
},
"whatsapp": {
"enabled": true,
"daily_limit": 5,
"scheduled_updates": 2
},
"sms": {
"enabled": true,
"daily_limit": 3
},
"ai_predictions": true,
"price_threshold": true,
"score_alerts": true
}
```
`fuel_types.max: null` means unlimited. `email.frequency` values: `weekly_digest`,
`daily`, `triggered`. `push.frequency` values: `none` (when disabled), `daily`,
`triggered`. All boolean features default `false` on free.
`database/seeders/PlanSeeder.php` is the source of truth. Per-tier reality:
- `price_threshold` and `score_alerts` are **enabled on basic, plus, and pro** (not plus-only).
- `ai_predictions` is **plus and pro only**.
- `whatsapp` and `sms` always carry `daily_limit` (and whatsapp carries `scheduled_updates`)
even when `enabled: false` — set to `0` on disabled tiers.
> Deeper per-tier feature flags (history window, prediction level, leaderboard size,
> saved stations, fuel log caps, brand comparison, route planner, family sharing) are
> defined in `docs/superpowers/specs/2026-04-15-tier-features-design.md` — that spec
> is the source of truth for entitlements beyond notification channels.
### `user_notification_preferences` table
```
id unsignedBigInteger PK
user_id unsignedBigInteger FK users.id cascadeDelete
channel string — email | push | whatsapp | sms
fuel_type string — E10 | E5 | B7_STANDARD | B7_PREMIUM | B10 | HVO
enabled boolean default true
timestamps
unique([user_id, channel, fuel_type])
```
The user opts channels in/out here. The tier is the ceiling — if the plan does
not allow a channel, this preference is ignored regardless of its value.
### `notification_log` table
```
id unsignedBigInteger PK
user_id unsignedBigInteger FK users.id cascadeDelete
channel string
trigger_type string — price_threshold | score_change | scheduled_morning | scheduled_evening
fuel_type string
price decimal(8,3) nullable
sent boolean
missed_reason string nullable — daily_limit | tier_restricted | user_disabled
created_at timestamp
```
No `updated_at` — this is append-only. Index on `(user_id, channel, created_at)`
for daily limit queries. Index on `(user_id, sent, created_at)` for dashboard
missed-count queries.
---
## Models
### `Plan`
- Casts `features` to `array`.
- Has a static `resolveForUser(User $user): Plan` method — looks up the user's
active Cashier subscription price ID, matches to either `stripe_price_id_monthly`
or `stripe_price_id_annual`, falls back to the `free` plan row.
- Cache the resolved plan: `Cache::tags(['plans'])->remember("plan_for_user_{$user->id}", 3600, ...)`.
- Bust `Cache::tags(['plans'])` in an Eloquent `saved` observer on `Plan`.
### `UserNotificationPreference`
- `belongsTo(User::class)`
- Scope: `scopeEnabled($query)` — where enabled = true
- Scope: `scopeForChannel($query, string $channel)`
- Scope: `scopeForFuelType($query, string $fuelType)`
### `NotificationLog`
- `belongsTo(User::class)`
- Scope: `scopeSentToday($query, string $channel)` — counts today's sent rows
- Scope: `scopeMissed($query)` — where sent = false
- Never update rows — only insert.
---
## `PlanFeatures` Service
Located at `app/Services/PlanFeatures.php`.
```php
PlanFeatures::for($user)->canUseChannel('sms') // bool — tier allows it
PlanFeatures::for($user)->canSendNow('sms') // bool — tier allows + under daily limit
PlanFeatures::for($user)->channelsFor('price_threshold') // string[] — allowed + user-enabled + under limit
PlanFeatures::for($user)->canTrackFuelType(string $type) // bool — under max
PlanFeatures::for($user)->trackedFuelTypeCount() // int
PlanFeatures::for($user)->fuelTypeLimit() // int|null
PlanFeatures::for($user)->can('ai_predictions') // bool — generic feature flag
PlanFeatures::for($user)->missedToday(string $channel) // int — for dashboard
PlanFeatures::for($user)->missedThisMonth(string $channel)// int — for digest email
PlanFeatures::for($user)->tier() // string — free|basic|plus|pro
```
**Rules:**
- `canSendNow` always checks both the plan cap AND the live `notification_log`
count for today. Never skip either check.
- `channelsFor($triggerType)` is the method used by the dispatch job. It returns
only channels that pass: tier allows → user has enabled → daily limit not hit.
- The service must never throw. If the plan cannot be resolved, treat as `free`.
- The service is the **only** place daily limit logic lives. Jobs and controllers
call `PlanFeatures`, never query `notification_log` directly for limit checks.
---
## `RequiresFeature` Middleware
Registered as `feature` in `bootstrap/app.php`.
```php
// Usage in routes
Route::get('/predictions', PredictionsController::class)
->middleware('feature:ai_predictions');
```
Returns `403 { "error": "upgrade_required", "feature": "ai_predictions" }` if
`PlanFeatures::for($request->user())->can($feature)` is false.
Only use for route-level feature gates. Channel-level logic stays in the job.
---
## Notification Dispatch Flow
### Price update (every 15 min)
```
PriceUpdated event (fired by polling job)
└── ProcessPriceAlerts job (queued, single instance via WithoutOverlapping)
├── Find users whose threshold >= new price for this fuel type
├── Find users subscribed to score_change if score flipped
├── Chunk users → dispatch DispatchUserNotification job per user
```
### `DispatchUserNotification` job
```
1. Load plan via Plan::resolveForUser($user) — cached
2. Instantiate PlanFeatures::for($user)
3. $channels = $features->channelsFor($triggerType) — filtered list
4. foreach $channels as $channel:
a. Send via the appropriate Laravel Notification class
b. Log to notification_log (sent: true)
5. foreach skipped channels (tier allows but limit hit):
a. Log to notification_log (sent: false, missed_reason: daily_limit)
6. foreach tier-blocked channels the user had enabled in prefs:
a. Log to notification_log (sent: false, missed_reason: tier_restricted)
```
Do not log channels the user has manually disabled (`user_disabled` would be
noise — those are intentional).
### Scheduled WhatsApp updates
Two scheduler entries:
```php
Schedule::job(SendScheduledWhatsApp::class, 'morning')->dailyAt('07:30');
Schedule::job(SendScheduledWhatsApp::class, 'evening')->dailyAt('18:00');
```
`SendScheduledWhatsApp` queries all users where:
- Plan has `whatsapp.scheduled_updates > 0`
- User has whatsapp preference enabled
- `canSendNow('whatsapp')` is true at dispatch time
Same logging rules apply.
---
## Filament `PlanResource`
Located in the admin panel. Edits the `features` JSON column using explicit form
fields — never a raw key-value editor.
**Form fields:**
```
Section: Fuel Types
- NumberInput fuel_types.max (null = unlimited, label: "Max fuel types — leave blank for unlimited")
Section: Email
- Toggle email.enabled
- Select email.frequency options: weekly_digest | daily | triggered
Section: Push
- Toggle push.enabled
Section: WhatsApp
- Toggle whatsapp.enabled
- NumberInput whatsapp.daily_limit
- NumberInput whatsapp.scheduled_updates
Section: SMS
- Toggle sms.enabled
- NumberInput sms.daily_limit
Section: Features
- Toggle ai_predictions
- Toggle price_threshold
- Toggle score_alerts
```
On save, bust `Cache::tags(['plans'])`.
Do not allow deleting plan rows — disable the `DeleteAction` on the resource.
Do not allow creating new plan rows from the UI — the four tiers are seeded.
---
## Filament Dashboard Widget — Missed Notifications
A `StatsOverviewWidget` on the user detail page (or a standalone widget) showing:
```
SMS missed today: 3 [Upgrade to Pro]
WhatsApp missed today: 0
Total missed this month: 12
```
Data sourced from `NotificationLog::scopeMissed()` queries. This data also feeds
the weekly/monthly digest email — the mailable receives the counts and renders
a "you missed X alerts — upgrade" block.
---
## Seeder
A `PlanSeeder` must exist that creates or updates all four plan rows with correct
default feature values. It must be idempotent (`updateOrCreate` on `name`).
Run as part of `DatabaseSeeder` in production-safe seeders.
```php
php artisan db:seed --class=PlanSeeder
```
---
## Testing Expectations
Every entitlement check must have a Pest feature test:
- `canUseChannel` returns false when tier doesn't allow it
- `canSendNow` returns false when daily limit is reached
- `channelsFor` returns correct filtered list for each tier
- `canTrackFuelType` enforces max correctly, null = unlimited
- Middleware returns 403 with correct JSON for missing feature
- `DispatchUserNotification` job logs missed_reason correctly
- `PlanSeeder` is idempotent
Use factories for `Plan`, `User`, `UserNotificationPreference`, `NotificationLog`.
The `Plan` factory should accept a `tier` state: `Plan::factory()->pro()->create()`.
---
## What Must Never Happen
- Never query `notification_log` for limit checks outside `PlanFeatures`
- Never hardcode tier names as strings outside `Plan::TIERS` constant or an Enum
- Never send a notification without logging it
- Never bypass `PlanFeatures` in a job or controller "just this once"
- Never allow the `features` JSON to be partially saved — always merge full shape
- Never add a new feature to the JSON without adding a corresponding method to
`PlanFeatures` and updating the `PlanSeeder`

30
.claude/settings.json Normal file
View File

@@ -0,0 +1,30 @@
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(.env)",
"Bash(cat .env)",
"Bash(cat ./.env)",
"Bash(head .env)",
"Bash(head ./.env)",
"Bash(tail .env)",
"Bash(tail ./.env)",
"Bash(less .env)",
"Bash(less ./.env)",
"Bash(more .env)",
"Bash(more ./.env)",
"Bash(grep * .env)",
"Bash(grep * ./.env)",
"Bash(rg * .env)",
"Bash(rg * ./.env)",
"Bash(awk * .env)",
"Bash(awk * ./.env)",
"Bash(php artisan migrate:fresh)",
"Bash(php artisan migrate:fresh *)",
"Bash(php artisan migrate:reset)",
"Bash(php artisan migrate:reset *)",
"Bash(php artisan db:wipe)",
"Bash(php artisan db:wipe *)"
]
}
}

View File

@@ -1,8 +1,8 @@
APP_NAME=Laravel APP_NAME="Fuel Finder"
APP_ENV=local APP_ENV=local
APP_KEY= APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://fuel-price.test
APP_LOCALE=en APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
@@ -20,18 +20,18 @@ LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
DB_CONNECTION=sqlite DB_CONNECTION=mysql
# DB_HOST=127.0.0.1 DB_HOST=127.0.0.1
# DB_PORT=3306 DB_PORT=3306
# DB_DATABASE=laravel DB_DATABASE=fuel-price
# DB_USERNAME=root DB_USERNAME=fuel-price
# DB_PASSWORD= DB_PASSWORD=password
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
SESSION_ENCRYPT=false SESSION_ENCRYPT=false
SESSION_PATH=/ SESSION_PATH=/
SESSION_DOMAIN=null SESSION_DOMAIN=.fuel-price.test
BROADCAST_CONNECTION=log BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local FILESYSTEM_DISK=local
@@ -64,4 +64,37 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
FUELALERT_API_KEY= FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-haiku-4-5
FRED_API_KEY=
ONESIGNAL_APP_ID=
ONESIGNAL_API_KEY=
VONAGE_KEY=
VONAGE_SECRET=
VONAGE_WHATSAPP_FROM=
VONAGE_SMS_FROM=FuelAlert
API_SECRET_KEY=
EIA_API_KEY=
LLM_PREDICTION_PROVIDER=anthropic
STRIPE_KEY=
STRIPE_SECRET=
STRIPE_WEBHOOK_SECRET=
CASHIER_CURRENCY=gbp
STRIPE_PRICE_BASIC_MONTHLY=price_1TM3cwJuhjW3IKHlJCHz0xmU
STRIPE_PRICE_BASIC_ANNUAL=price_1TM3nlJuhjW3IKHlwcHF5W9v
STRIPE_PRICE_PLUS_MONTHLY=price_1TM3oqJuhjW3IKHlbQUMhrnm
STRIPE_PRICE_PLUS_ANNUAL=price_1TM3pXJuhjW3IKHlfQenHsf1
STRIPE_PRICE_PRO_MONTHLY=
STRIPE_PRICE_PRO_ANNUAL=
SANCTUM_STATEFUL_DOMAINS=fuel-price.test

2
.gitignore vendored
View File

@@ -22,3 +22,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,7 +45,9 @@ npm run dev # Vite asset watcher
@.claude/rules/database.md @.claude/rules/database.md
@.claude/rules/notifications.md @.claude/rules/notifications.md
@.claude/rules/scoring.md @.claude/rules/scoring.md
@.claude/rules/prediction.md
@.claude/rules/payments.md @.claude/rules/payments.md
@.claude/rules/tiers.md
@.claude/rules/livewire.md @.claude/rules/livewire.md
@.claude/rules/api-data.md @.claude/rules/api-data.md
@.claude/rules/testing.md @.claude/rules/testing.md

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,40 @@
<?php
namespace App\Console\Commands;
use App\Services\BrentPriceFetcher;
use App\Services\BrentPriceSources\BrentPriceFetchException;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
#[Signature('oil:fetch')]
#[Description('Fetch latest Brent crude prices (EIA primary, FRED fallback)')]
class FetchOilPrices extends Command
{
public function handle(BrentPriceFetcher $fetcher): int
{
try {
$fetcher->fetchFromEia();
$this->info('Fetched Brent prices from EIA.');
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::warning('FetchOilPrices: EIA fetch failed, falling back to FRED', ['error' => $e->getMessage()]);
$this->warn('EIA fetch failed: '.$e->getMessage().'. Trying FRED...');
}
try {
$fetcher->fetchFromFred();
$this->info('Fetched Brent prices from FRED.');
return self::SUCCESS;
} catch (BrentPriceFetchException $e) {
Log::error('FetchOilPrices: both EIA and FRED failed', ['error' => $e->getMessage()]);
$this->error('Both EIA and FRED failed: '.$e->getMessage());
return self::FAILURE;
}
}
}

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

@@ -2,26 +2,37 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Services\OilPriceService; use App\Services\BrentPricePredictor;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Throwable; use Throwable;
class PredictOilPrices extends Command class PredictOilPrices extends Command
{ {
protected $signature = 'oil:predict {--fetch : Fetch latest FRED prices before predicting}'; protected $signature = 'oil:predict {--force : Generate even if the latest price already has a prediction}';
protected $description = 'Generate a Brent crude oil price direction prediction'; protected $description = 'Generate a Brent crude oil price direction prediction';
public function handle(OilPriceService $service): int public function handle(BrentPricePredictor $predictor): int
{ {
try { try {
if ($this->option('fetch')) { $latest = $predictor->latestPrice();
$this->info('Fetching latest Brent crude prices from FRED...');
$service->fetchBrentPrices(); if ($latest?->prediction_generated_at !== null && ! $this->option('force')) {
$message = sprintf(
'Prediction already generated for %s at %s.',
$latest->date->toDateString(),
$latest->prediction_generated_at->toDateTimeString(),
);
if (! $this->confirm($message.' Run again anyway?', default: false)) {
$this->info('Skipped.');
return self::SUCCESS;
}
} }
$this->info('Generating prediction...'); $this->info('Generating prediction...');
$prediction = $service->generatePrediction(); $prediction = $predictor->generatePrediction();
if ($prediction === null) { if ($prediction === null) {
$this->error('Could not generate a prediction — not enough price data.'); $this->error('Could not generate a prediction — not enough price data.');

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

21
app/Enums/PlanTier.php Normal file
View File

@@ -0,0 +1,21 @@
<?php
namespace App\Enums;
enum PlanTier: string
{
case Free = 'free';
case Basic = 'basic';
case Plus = 'plus';
case Pro = 'pro';
public function label(): string
{
return match ($this) {
self::Free => 'Free',
self::Basic => 'Daily',
self::Plus => 'Smart',
self::Pro => 'Pro',
};
}
}

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

@@ -4,7 +4,10 @@ namespace App\Filament\Resources\BrentPriceResource\Pages;
use App\Filament\Resources\BrentPriceResource; use App\Filament\Resources\BrentPriceResource;
use App\Filament\Widgets\BrentPriceChartWidget; use App\Filament\Widgets\BrentPriceChartWidget;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\ListRecords; use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListBrentPrices extends ListRecords class ListBrentPrices extends ListRecords
{ {
@@ -12,7 +15,30 @@ class ListBrentPrices extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return []; return [
Action::make('fetchPrices')
->label('Fetch Prices Now')
->icon('heroicon-o-arrow-down-tray')
->requiresConfirmation()
->modalHeading('Fetch latest Brent prices?')
->modalDescription('Pulls the latest Brent crude prices from EIA (falls back to FRED if EIA is unavailable).')
->action(function () {
$result = Artisan::call('oil:fetch');
if ($result === 0) {
Notification::make()
->title('Brent prices fetched successfully')
->success()
->send();
} else {
Notification::make()
->title('Fetch failed')
->body('Both EIA and FRED failed. Check API Logs for details.')
->danger()
->send();
}
}),
];
} }
protected function getHeaderWidgets(): array protected function getHeaderWidgets(): array

View File

@@ -20,9 +20,9 @@ class ListOilPredictions extends ListRecords
->icon('heroicon-o-cpu-chip') ->icon('heroicon-o-cpu-chip')
->requiresConfirmation() ->requiresConfirmation()
->modalHeading('Run oil price prediction?') ->modalHeading('Run oil price prediction?')
->modalDescription('This will fetch the latest FRED prices and generate a new prediction. May take a few seconds.') ->modalDescription('Generates a new prediction from the stored Brent prices. Runs even if a prediction already exists for the latest price.')
->action(function () { ->action(function () {
$result = Artisan::call('oil:predict', ['--fetch' => true]); $result = Artisan::call('oil:predict', ['--force' => true]);
if ($result === 0) { if ($result === 0) {
Notification::make() Notification::make()

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Filament\Resources\Plans\Pages;
use App\Filament\Resources\Plans\PlanResource;
use Filament\Resources\Pages\EditRecord;
use Illuminate\Support\Facades\Cache;
class EditPlan extends EditRecord
{
protected static string $resource = PlanResource::class;
protected function getHeaderActions(): array
{
return [];
}
protected function afterSave(): void
{
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
}
}
}

View File

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

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Plans;
use App\Filament\NavigationGroup;
use App\Filament\Resources\Plans\Pages\EditPlan;
use App\Filament\Resources\Plans\Pages\ListPlans;
use App\Filament\Resources\Plans\Schemas\PlanForm;
use App\Filament\Resources\Plans\Tables\PlansTable;
use App\Models\Plan;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Table;
class PlanResource extends Resource
{
protected static ?string $model = Plan::class;
protected static string|\UnitEnum|null $navigationGroup = NavigationGroup::System;
protected static ?int $navigationSort = 10;
public static function form(Schema $schema): Schema
{
return PlanForm::configure($schema);
}
public static function table(Table $table): Table
{
return PlansTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListPlans::route('/'),
'edit' => EditPlan::route('/{record}/edit'),
];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Filament\Resources\Plans\Schemas;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PlanForm
{
public static function configure(Schema $schema): Schema
{
return $schema
->components([
Section::make('Fuel Types')
->schema([
TextInput::make('max_fuel_types')
->label('Max fuel types')
->helperText('Leave blank for unlimited.')
->numeric()
->integer()
->minValue(1)
->nullable(),
]),
Section::make('Email')
->columns(2)
->schema([
Toggle::make('email_enabled')
->label('Enabled'),
Select::make('email_frequency')
->label('Frequency')
->options([
'weekly_digest' => 'Weekly digest',
'daily' => 'Daily',
'triggered' => 'Triggered',
]),
]),
Section::make('Push')
->columns(2)
->schema([
Toggle::make('push_enabled')
->label('Enabled'),
Select::make('push_frequency')
->label('Frequency')
->options([
'none' => 'None (disabled)',
'daily' => 'Daily',
'triggered' => 'Triggered',
]),
]),
Section::make('WhatsApp')
->columns(3)
->schema([
Toggle::make('whatsapp_enabled')
->label('Enabled'),
TextInput::make('whatsapp_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
TextInput::make('whatsapp_scheduled_updates')
->label('Scheduled updates per day')
->numeric()
->integer()
->minValue(0)
->required(),
]),
Section::make('SMS')
->columns(2)
->schema([
Toggle::make('sms_enabled')
->label('Enabled'),
TextInput::make('sms_daily_limit')
->label('Daily limit')
->numeric()
->integer()
->minValue(0)
->required(),
]),
Section::make('Features')
->schema([
Toggle::make('ai_predictions')
->label('AI predictions'),
Toggle::make('price_threshold')
->label('Price threshold alerts'),
Toggle::make('score_alerts')
->label('Score change alerts'),
]),
]);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Filament\Resources\Plans\Tables;
use Filament\Actions\EditAction;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
class PlansTable
{
public static function configure(Table $table): Table
{
return $table
->columns([
TextColumn::make('name')
->label('Tier')
->badge()
->sortable(),
TextColumn::make('email_frequency')
->label('Email')
->placeholder('—'),
TextColumn::make('sms_daily_limit')
->label('SMS/day')
->placeholder('—'),
TextColumn::make('whatsapp_daily_limit')
->label('WhatsApp/day')
->placeholder('—'),
TextColumn::make('max_fuel_types')
->label('Fuel types')
->placeholder('Unlimited'),
IconColumn::make('active')
->boolean(),
])
->defaultSort('id', 'asc')
->recordActions([
EditAction::make(),
]);
}
}

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

@@ -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

@@ -0,0 +1,30 @@
<?php
namespace App\Http\Middleware;
use App\Services\PlanFeatures;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class RequiresFeature
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, string $feature): Response
{
$user = $request->user();
if (! $user || ! PlanFeatures::for($user)->can($feature)) {
return response()->json([
'error' => 'upgrade_required',
'feature' => $feature,
], Response::HTTP_FORBIDDEN);
}
return $next($request);
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use Closure; use Closure;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
final class VerifyApiKey final class VerifyApiKey
@@ -20,6 +21,10 @@ final class VerifyApiKey
return $next($request); return $next($request);
} }
if (EnsureFrontendRequestsAreStateful::fromFrontend($request)) {
return $next($request);
}
if ($request->header('X-Api-Key') !== config('app.api_secret_key')) { if ($request->header('X-Api-Key') !== config('app.api_secret_key')) {
abort(403); abort(403);
} }

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

@@ -0,0 +1,99 @@
<?php
namespace App\Jobs;
use App\Models\NotificationLog;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Notifications\FuelPriceAlert;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
/**
* Resolves allowed notification channels for a user and trigger, dispatches
* the FuelPriceAlert notification (which fans out to email + push + WhatsApp +
* SMS), and logs every outcome (sent, daily_limit, tier_restricted).
*/
final class DispatchUserNotificationJob implements ShouldQueue
{
use Queueable;
/** @var string[] */
private const array ALL_CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
public function __construct(
public readonly User $user,
public readonly string $triggerType,
public readonly string $fuelType,
public readonly ?float $price = null,
) {
$this->onQueue('notifications');
}
public function handle(): void
{
$features = PlanFeatures::for($this->user);
// Step 3: channels that pass tier + user-pref + daily-limit checks
$allowed = $features->channelsFor($this->triggerType);
// Step 4: dispatch the multi-channel notification — Laravel fans out
// to mail / OneSignal / Vonage WhatsApp / Vonage SMS based on via().
if ($allowed !== []) {
$this->user->notify(new FuelPriceAlert(
$this->triggerType,
$this->fuelType,
$this->price,
$allowed,
));
}
// Step 5: log a sent entry per allowed channel. The notify() call
// above queues per-channel sends; per-channel HTTP outcomes are
// captured in api_logs by the channel adapters themselves.
foreach ($allowed as $channel) {
$this->log($channel, sent: true);
}
// Channels not in the allowed set — split into missed reasons
$notAllowed = array_diff(self::ALL_CHANNELS, $allowed);
foreach ($notAllowed as $channel) {
if (! $this->userHasEnabledPref($channel)) {
// User intentionally disabled — do not log (noise)
continue;
}
if ($features->canUseChannel($channel)) {
// Step 5: tier allows but daily limit exhausted
$this->log($channel, sent: false, missedReason: 'daily_limit');
} else {
// Step 6: tier does not allow the channel the user wanted
$this->log($channel, sent: false, missedReason: 'tier_restricted');
}
}
}
private function log(string $channel, bool $sent, ?string $missedReason = null): void
{
NotificationLog::create([
'user_id' => $this->user->id,
'channel' => $channel,
'trigger_type' => $this->triggerType,
'fuel_type' => $this->fuelType,
'price' => $this->price,
'sent' => $sent,
'missed_reason' => $missedReason,
'created_at' => now(),
]);
}
private function userHasEnabledPref(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
}

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

@@ -0,0 +1,53 @@
<?php
namespace App\Jobs;
use App\Models\User;
use App\Models\UserNotificationPreference;
use App\Services\PlanFeatures;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Queue\Queueable;
/**
* Fan-out job for scheduled WhatsApp updates (morning / evening).
* Dispatches one DispatchUserNotificationJob per eligible user so each
* user's send is its own queueable unit (independent retry, no shared
* failure mode across the cohort).
*
* Scheduled at 07:30 (morning) and 18:00 (evening) via routes/console.php.
*/
final class SendScheduledWhatsAppJob implements ShouldQueue
{
use Queueable;
public function __construct(public readonly string $period)
{
//
}
public function handle(): void
{
$triggerType = $this->period === 'morning' ? 'scheduled_morning' : 'scheduled_evening';
// Candidates: users who have explicitly opted in to WhatsApp.
// Per-user tier + daily-limit + scheduled-updates checks happen via
// canSendNow('whatsapp'); that single call covers tier eligibility
// (canUseChannel) AND today's notification_log count.
$userIds = UserNotificationPreference::where('channel', 'whatsapp')
->where('enabled', true)
->distinct()
->pluck('user_id');
User::whereIn('id', $userIds)
->chunkById(500, function (Collection $users) use ($triggerType): void {
foreach ($users as $user) {
if (! PlanFeatures::for($user)->canSendNow('whatsapp')) {
continue;
}
DispatchUserNotificationJob::dispatch($user, $triggerType, fuelType: 'all');
}
});
}
}

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

@@ -6,9 +6,8 @@ use Database\Factories\BrentPriceFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['date', 'price_usd'])] #[Fillable(['date', 'price_usd', 'prediction_generated_at'])]
class BrentPrice extends Model class BrentPrice extends Model
{ {
/** @use HasFactory<BrentPriceFactory> */ /** @use HasFactory<BrentPriceFactory> */
@@ -27,6 +26,7 @@ class BrentPrice extends Model
return [ return [
'date' => 'date', 'date' => 'date',
'price_usd' => 'decimal:2', 'price_usd' => 'decimal:2',
'prediction_generated_at' => 'datetime',
]; ];
} }
} }

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Models;
use Database\Factories\NotificationLogFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class NotificationLog extends Model
{
/** @use HasFactory<NotificationLogFactory> */
use HasFactory;
public $timestamps = false;
protected $table = 'notification_log';
protected $fillable = [
'user_id',
'channel',
'trigger_type',
'fuel_type',
'price',
'sent',
'missed_reason',
'created_at',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** Count sent notifications for this channel today. */
public function scopeSentToday(Builder $query, string $channel): void
{
$query->where('channel', $channel)
->where('sent', true)
->whereDate('created_at', today());
}
/** Notifications that were not sent. */
public function scopeMissed(Builder $query): void
{
$query->where('sent', false);
}
protected function casts(): array
{
return [
'sent' => 'boolean',
'price' => 'decimal:3',
'created_at' => 'datetime',
];
}
}

26
app/Models/Outcode.php Normal file
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',
];
}
}

151
app/Models/Plan.php Normal file
View File

@@ -0,0 +1,151 @@
<?php
namespace App\Models;
use App\Enums\PlanTier;
use Database\Factories\PlanFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class Plan extends Model
{
/** @use HasFactory<PlanFactory> */
use HasFactory;
protected $fillable = [
'name',
'stripe_price_id_monthly',
'stripe_price_id_annual',
'max_fuel_types',
'email_enabled',
'email_frequency',
'push_enabled',
'push_frequency',
'whatsapp_enabled',
'whatsapp_daily_limit',
'whatsapp_scheduled_updates',
'sms_enabled',
'sms_daily_limit',
'ai_predictions',
'price_threshold',
'score_alerts',
'active',
];
/**
* Resolve the active plan for a user.
* Falls back to the free plan when no active Cashier subscription exists.
*/
public static function resolveForUser(User $user): self
{
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
$planId = $cache->remember(
"plan_for_user_{$user->id}",
3600,
function () use ($user): ?int {
$priceId = null;
if (method_exists($user, 'subscriptions')) {
$subscription = $user->subscriptions()->active()->first();
$priceId = $subscription?->stripe_price ?? null;
}
if ($priceId) {
$plan = static::where(fn ($q) => $q
->where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId))
->where('active', true)
->first();
if ($plan) {
return $plan->id;
}
}
return static::where('name', PlanTier::Free->value)->value('id');
}
);
return static::findOrFail($planId);
}
/**
* Resolve the active subscription cadence for a user.
* Returns 'monthly' | 'annual', or null if the user has no paid subscription.
*/
public static function resolveCadenceForUser(User $user): ?string
{
$cache = Cache::supportsTags() ? Cache::tags(['plans']) : Cache::store();
return $cache->remember(
"plan_cadence_for_user_{$user->id}",
3600,
function () use ($user): ?string {
if (! method_exists($user, 'subscriptions')) {
return null;
}
$priceId = $user->subscriptions()->active()->value('stripe_price');
if ($priceId === null) {
return null;
}
$plan = static::where('stripe_price_id_monthly', $priceId)
->orWhere('stripe_price_id_annual', $priceId)
->first();
if ($plan === null) {
return null;
}
if ($plan->stripe_price_id_monthly === $priceId) {
return 'monthly';
}
if ($plan->stripe_price_id_annual === $priceId) {
return 'annual';
}
return null;
}
);
}
protected static function booted(): void
{
static::saved(function (): void {
if (Cache::supportsTags()) {
Cache::tags(['plans'])->flush();
}
});
}
protected function casts(): array
{
return [
'max_fuel_types' => 'integer',
'email_enabled' => 'boolean',
'push_enabled' => 'boolean',
'whatsapp_enabled' => 'boolean',
'whatsapp_daily_limit' => 'integer',
'whatsapp_scheduled_updates' => 'integer',
'sms_enabled' => 'boolean',
'sms_daily_limit' => 'integer',
'ai_predictions' => 'boolean',
'price_threshold' => 'boolean',
'score_alerts' => 'boolean',
'active' => 'boolean',
];
}
/** User-facing display label for this plan (e.g. basic → "Daily"). */
public function displayName(): string
{
$tier = PlanTier::tryFrom((string) $this->name) ?? PlanTier::Free;
return $tier->label();
}
}

26
app/Models/Postcode.php Normal file
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

@@ -9,7 +9,6 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
#[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])]
class PricePrediction extends Model class PricePrediction extends Model
@@ -39,11 +38,17 @@ class PricePrediction extends Model
*/ */
public function scopeBestFirst(Builder $query): Builder public function scopeBestFirst(Builder $query): Builder
{ {
$priority = implode(', ', array_map( $priority = [
fn (string $v) => "'$v'", PredictionSource::LlmWithContext->value,
[PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value], PredictionSource::Llm->value,
)); PredictionSource::Ewma->value,
];
return $query->orderByRaw("FIELD(source, $priority)"); $cases = '';
foreach ($priority as $rank => $source) {
$cases .= " WHEN '$source' THEN $rank";
}
return $query->orderByRaw("CASE source$cases ELSE ".count($priority).' END');
} }
} }

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',
]; ];
} }
@@ -58,4 +60,14 @@ class User extends Authenticatable implements FilamentUser
{ {
return $this->hasMany(SavedStation::class); return $this->hasMany(SavedStation::class);
} }
public function notificationPreferences(): HasMany
{
return $this->hasMany(UserNotificationPreference::class);
}
public function notificationLogs(): HasMany
{
return $this->hasMany(NotificationLog::class);
}
} }

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use App\Enums\FuelType;
use Database\Factories\UserNotificationPreferenceFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class UserNotificationPreference extends Model
{
/** @use HasFactory<UserNotificationPreferenceFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'channel',
'fuel_type',
'enabled',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function scopeEnabled(Builder $query): void
{
$query->where('enabled', true);
}
public function scopeForChannel(Builder $query, string $channel): void
{
$query->where('channel', $channel);
}
public function scopeForFuelType(Builder $query, string $fuelType): void
{
$query->where('fuel_type', $fuelType);
}
protected function casts(): array
{
return [
'enabled' => 'boolean',
'fuel_type' => FuelType::class,
];
}
}

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,6 +2,8 @@
namespace App\Providers; namespace App\Providers;
use App\Listeners\HandleStripeWebhook;
use App\Models\Subscription;
use App\Services\ApiLogger; use App\Services\ApiLogger;
use App\Services\LlmPrediction\AnthropicPredictionProvider; use App\Services\LlmPrediction\AnthropicPredictionProvider;
use App\Services\LlmPrediction\GeminiPredictionProvider; use App\Services\LlmPrediction\GeminiPredictionProvider;
@@ -10,8 +12,11 @@ use App\Services\LlmPrediction\OpenAiPredictionProvider;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password; use Illuminate\Validation\Rules\Password;
use Laravel\Cashier\Cashier;
use Laravel\Cashier\Events\WebhookReceived;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -37,6 +42,10 @@ class AppServiceProvider extends ServiceProvider
public function boot(): void public function boot(): void
{ {
$this->configureDefaults(); $this->configureDefaults();
Cashier::useSubscriptionModel(Subscription::class);
Event::listen(WebhookReceived::class, HandleStripeWebhook::class);
} }
/** /**
@@ -50,13 +59,6 @@ class AppServiceProvider extends ServiceProvider
app()->isProduction(), app()->isProduction(),
); );
// SQLite lacks GREATEST/LEAST scalar functions — register them for tests.
if (DB::connection()->getDriverName() === 'sqlite') {
$pdo = DB::connection()->getPdo();
$pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1);
$pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1);
}
Password::defaults(fn (): ?Password => app()->isProduction() Password::defaults(fn (): ?Password => app()->isProduction()
? Password::min(12) ? Password::min(12)
->mixedCase() ->mixedCase()

View File

@@ -4,6 +4,7 @@ namespace App\Services;
use App\Models\ApiLog; use App\Models\ApiLog;
use Illuminate\Http\Client\Response; use Illuminate\Http\Client\Response;
use Illuminate\Support\Str;
use Throwable; use Throwable;
class ApiLogger class ApiLogger
@@ -26,6 +27,10 @@ class ApiLogger
$response = $request(); $response = $request();
$statusCode = $response->status(); $statusCode = $response->status();
if ($response->failed()) {
$error = Str::limit($response->body(), 1000);
}
return $response; return $response;
} catch (Throwable $e) { } catch (Throwable $e) {
$error = $e->getMessage(); $error = $e->getMessage();

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Services;
use App\Models\BrentPrice;
use App\Services\BrentPriceSources\BrentPriceFetchException;
use App\Services\BrentPriceSources\EiaBrentPriceSource;
use App\Services\BrentPriceSources\FredBrentPriceSource;
final readonly class BrentPriceFetcher
{
public function __construct(
private readonly EiaBrentPriceSource $eia,
private readonly FredBrentPriceSource $fred,
) {}
/**
* Fetch from EIA and persist. Throws on failure.
*/
public function fetchFromEia(): void
{
$rows = $this->eia->fetch();
if ($rows === null) {
throw new BrentPriceFetchException('EIA fetch returned no data');
}
BrentPrice::upsert($rows, ['date'], ['price_usd']);
}
/**
* Fetch from FRED and persist. Throws on failure.
*/
public function fetchFromFred(): void
{
$rows = $this->fred->fetch();
if ($rows === null) {
throw new BrentPriceFetchException('FRED fetch returned no data');
}
BrentPrice::upsert($rows, ['date'], ['price_usd']);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
final class BrentPricePredictor
{
private const float EWMA_THRESHOLD_PCT = 1.5;
private const int EWMA_MAX_CONFIDENCE = 65;
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly OilPredictionProvider $provider,
) {}
/**
* Return the latest BrentPrice row, or null if none exists.
*/
public function latestPrice(): ?BrentPrice
{
return BrentPrice::orderBy('date', 'desc')->first();
}
/**
* Try LLM first; persist EWMA only as a fallback when the LLM provider
* returns null. The downstream OilSignal already prefers LLM
* (llm_with_context > llm > ewma), so writing both rows on every run is
* dead weight 95% of the time. EWMA still acts as the safety net.
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('BrentPricePredictor: not enough price data', [
'rows' => $prices->count(),
]);
return null;
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
return $llm;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
$prices->first()->forceFill(['prediction_generated_at' => now()])->save();
}
return $ewma;
}
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = Ewma::compute(array_slice($chronological, -3));
$ewma7 = Ewma::compute(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Services\BrentPriceSources;
use RuntimeException;
final class BrentPriceFetchException extends RuntimeException {}

View File

@@ -0,0 +1,59 @@
<?php
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final class EiaBrentPriceSource
{
private const string URL = 'https://api.eia.gov/v2/petroleum/pri/spt/data/';
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('eia', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'api_key' => config('services.eia.api_key'),
'frequency' => 'daily',
'data[0]' => 'value',
'facets[series][]' => 'RBRTE',
'sort[0][column]' => 'period',
'sort[0][direction]' => 'desc',
'length' => 30,
]));
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("EIA connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("EIA returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('response.data') ?? [])
->filter(fn (array $row) => ($row['value'] ?? '.') !== '.')
->map(fn (array $row) => [
'date' => $row['period'],
'price_usd' => (float) $row['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace App\Services\BrentPriceSources;
use App\Services\ApiLogger;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Throwable;
final class FredBrentPriceSource
{
private const string URL = 'https://api.stlouisfed.org/fred/series/observations';
public function __construct(private readonly ApiLogger $apiLogger) {}
/**
* @return array{date: string, price_usd: float}[]|null null only when the response carried no usable rows
*
* @throws BrentPriceFetchException on network failure or non-2xx response after retries
*/
public function fetch(): ?array
{
try {
$response = $this->apiLogger->send('fred', 'GET', self::URL, fn () => Http::timeout(30)
->retry(3, 200, fn (Throwable $e) => $this->shouldRetry($e))
->throw()
->get(self::URL, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'sort_order' => 'desc',
'limit' => 30,
'file_type' => 'json',
]));
} catch (ConnectionException $e) {
throw new BrentPriceFetchException("FRED connection failed: {$e->getMessage()}", previous: $e);
} catch (RequestException $e) {
throw new BrentPriceFetchException("FRED returned HTTP {$e->response->status()}", previous: $e);
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.')
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
return $rows === [] ? null : $rows;
}
private function shouldRetry(Throwable $e): bool
{
return $e instanceof ConnectionException
|| ($e instanceof RequestException && $e->response->serverError());
}
}

25
app/Services/Ewma.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
namespace App\Services;
/**
* Exponentially-weighted moving average. Pure function used by
* BrentPricePredictor for the EWMA fallback prediction and by
* AnthropicPredictionProvider to enrich the basic-flow prompt.
*/
final class Ewma
{
public const float DEFAULT_ALPHA = 0.3;
/** @param float[] $prices Chronological order (oldest first). */
public static function compute(array $prices, float $alpha = self::DEFAULT_ALPHA): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = $alpha * $price + (1 - $alpha) * $ema;
}
return round($ema, 4);
}
}

View File

@@ -6,6 +6,7 @@ use App\Enums\FuelType;
use App\Models\Station; use App\Models\Station;
use App\Models\StationPrice; use App\Models\StationPrice;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use Carbon\CarbonInterface;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -17,6 +18,8 @@ class FuelPriceService
{ {
private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token'; private const string TOKEN_CACHE_KEY = 'fuel_finder_access_token';
private const string LAST_PRICE_POLL_CACHE_KEY = 'fuel_finder_last_price_poll_at';
/** /**
* Per-fuel-type valid price range in pence (as returned by the API). * Per-fuel-type valid price range in pence (as returned by the API).
* Based on UK all-time records + 3075% headroom for future spikes. * Based on UK all-time records + 3075% headroom for future spikes.
@@ -42,7 +45,8 @@ class FuelPriceService
{ {
return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string { return Cache::remember(self::TOKEN_CACHE_KEY, 3540, function (): string {
$url = config('services.fuel_finder.base_url').'/oauth/generate_access_token'; $url = config('services.fuel_finder.base_url').'/oauth/generate_access_token';
$response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::timeout(10) $response = $this->apiLogger->send('fuel_finder', 'POST', $url, fn () => Http::retry(3, 500)
->timeout(60)
->post($url, [ ->post($url, [
'client_id' => config('services.fuel_finder.client_id'), 'client_id' => config('services.fuel_finder.client_id'),
'client_secret' => config('services.fuel_finder.client_secret'), 'client_secret' => config('services.fuel_finder.client_secret'),
@@ -55,51 +59,27 @@ class FuelPriceService
/** /**
* Poll the prices endpoint, deduplicate, and persist changes. * Poll the prices endpoint, deduplicate, and persist changes.
* *
* Uses incremental polling when a previous poll timestamp is cached only
* stations with prices changed since then are returned by the API. Falls
* back to a full fetch on cold start (cache miss).
*
* @return int Number of new price records inserted * @return int Number of new price records inserted
*/ */
public function pollPrices(): int public function pollPrices(): int
{ {
$token = $this->getAccessToken(); $pollStartedAt = now();
$inserted = 0; $since = Cache::get(self::LAST_PRICE_POLL_CACHE_KEY);
$batch = 1; $sinceCarbon = $since instanceof CarbonInterface ? $since : null;
do { [$inserted, $completedCleanly] = $this->iterateBatches(
try { '/pfs/fuel-prices',
$baseUrl = config('services.fuel_finder.base_url').'/pfs/fuel-prices'; $sinceCarbon,
$params = ['batch-number' => $batch]; fn (array $stations): int => $this->processPriceBatch($stations),
$logUrl = $baseUrl.'?'.http_build_query($params); );
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
->withToken($token)
->get($baseUrl, $params));
if ($response->notFound()) { if ($completedCleanly) {
break; // No more batches Cache::forever(self::LAST_PRICE_POLL_CACHE_KEY, $pollStartedAt);
} }
if (! $response->successful()) {
Log::error('FuelPriceService: price batch returned error', [
'batch' => $batch,
'status' => $response->status(),
]);
break;
}
$stations = $response->json() ?? [];
} catch (Throwable $e) {
Log::error('FuelPriceService: price batch fetch failed', [
'batch' => $batch,
'error' => $e->getMessage(),
]);
break;
}
if (empty($stations)) {
break;
}
$inserted += $this->processPriceBatch($stations);
$batch++;
} while (true);
return $inserted; return $inserted;
} }
@@ -109,25 +89,53 @@ class FuelPriceService
* Called on full daily refresh before pollPrices(). * Called on full daily refresh before pollPrices().
*/ */
public function refreshStations(): void public function refreshStations(): void
{
$this->iterateBatches('/pfs', null, function (array $stations): int {
$this->upsertStations($stations);
return 0;
});
}
/**
* Drive a paginated fuel-finder endpoint until exhausted, calling
* $process on each non-empty batch. Returns the sum of $process return
* values plus a flag indicating the loop exited cleanly (404 or empty
* body) rather than via an HTTP error or thrown exception. Callers use
* the flag to decide whether to update incremental-poll bookkeeping.
*
* @param callable(array<int, array<string, mixed>>): int $process
* @return array{0: int, 1: bool}
*/
private function iterateBatches(string $endpoint, ?CarbonInterface $since, callable $process): array
{ {
$token = $this->getAccessToken(); $token = $this->getAccessToken();
$baseUrl = config('services.fuel_finder.base_url').$endpoint;
$total = 0;
$batch = 1; $batch = 1;
$completedCleanly = false;
do { do {
try { try {
$baseUrl = config('services.fuel_finder.base_url').'/pfs';
$params = ['batch-number' => $batch]; $params = ['batch-number' => $batch];
if ($since !== null) {
$params['effective-start-timestamp'] = $since->format('Y-m-d H:i:s');
}
$logUrl = $baseUrl.'?'.http_build_query($params); $logUrl = $baseUrl.'?'.http_build_query($params);
$response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30) $response = $this->apiLogger->send('fuel_finder', 'GET', $logUrl, fn () => Http::timeout(30)
->withToken($token) ->withToken($token)
->get($baseUrl, $params)); ->get($baseUrl, $params));
if ($response->notFound()) { if ($response->notFound()) {
break; // No more batches $completedCleanly = true;
break;
} }
if (! $response->successful()) { if (! $response->successful()) {
Log::error('FuelPriceService: station batch returned error', [ Log::error('FuelPriceService: batch returned error', [
'endpoint' => $endpoint,
'batch' => $batch, 'batch' => $batch,
'status' => $response->status(), 'status' => $response->status(),
]); ]);
@@ -136,7 +144,8 @@ class FuelPriceService
$stations = $response->json() ?? []; $stations = $response->json() ?? [];
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('FuelPriceService: station batch fetch failed', [ Log::error('FuelPriceService: batch fetch failed', [
'endpoint' => $endpoint,
'batch' => $batch, 'batch' => $batch,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
@@ -144,12 +153,15 @@ class FuelPriceService
} }
if (empty($stations)) { if (empty($stations)) {
$completedCleanly = true;
break; break;
} }
$this->upsertStations($stations); $total += $process($stations);
$batch++; $batch++;
} while (true); } while (true);
return [$total, $completedCleanly];
} }
/** @param array<int, array<string, mixed>> $apiStations */ /** @param array<int, array<string, mixed>> $apiStations */
@@ -159,6 +171,14 @@ class FuelPriceService
$rows = []; $rows = [];
foreach ($apiStations as $data) { foreach ($apiStations as $data) {
if (! $this->hasRequiredStationFields($data)) {
Log::warning('FuelPriceService: station skipped — missing required fields', [
'node_id' => $data['node_id'] ?? null,
]);
continue;
}
$station = new Station([ $station = new Station([
'node_id' => $data['node_id'], 'node_id' => $data['node_id'],
'trading_name' => $data['trading_name'], 'trading_name' => $data['trading_name'],
@@ -179,9 +199,9 @@ class FuelPriceService
'postcode' => $data['location']['postcode'], 'postcode' => $data['location']['postcode'],
'lat' => $data['location']['latitude'], 'lat' => $data['location']['latitude'],
'lng' => $data['location']['longitude'], 'lng' => $data['location']['longitude'],
'amenities' => $data['amenities'] ?? [], 'amenities' => $this->flattenEnabledFlags($data['amenities'] ?? []),
'opening_times' => $data['opening_times'] ?? null, 'opening_times' => $data['opening_times'] ?? null,
'fuel_types' => $data['fuel_types'] ?? [], 'fuel_types' => $this->flattenEnabledFlags($data['fuel_types'] ?? []),
'last_seen_at' => $now, 'last_seen_at' => $now,
]); ]);
@@ -189,7 +209,40 @@ class FuelPriceService
$rows[] = $station->getAttributes(); $rows[] = $station->getAttributes();
} }
Station::upsert($rows, ['node_id'], array_keys($rows[0] ?? [])); if ($rows === []) {
return;
}
Station::upsert($rows, ['node_id'], array_keys($rows[0]));
}
/** @param array<string, mixed> $data */
private function hasRequiredStationFields(array $data): bool
{
return ! empty($data['node_id'])
&& ! empty($data['trading_name'])
&& isset($data['location']['postcode'], $data['location']['latitude'], $data['location']['longitude']);
}
/**
* The API returns `amenities` and `fuel_types` as objects with boolean
* flags (e.g. {"E10": true, "car_wash": false}). Flatten to a list of
* enabled keys. If the payload is already an array of strings, return as-is.
*
* @param array<string, bool>|array<int, string> $flags
* @return array<int, string>
*/
private function flattenEnabledFlags(array $flags): array
{
if ($flags === []) {
return [];
}
if (array_is_list($flags)) {
return array_values($flags);
}
return array_values(array_keys(array_filter($flags, fn ($v) => filter_var($v, FILTER_VALIDATE_BOOLEAN))));
} }
private function isValidPrice(FuelType $fuelType, float $pricePence): bool private function isValidPrice(FuelType $fuelType, float $pricePence): bool
@@ -216,8 +269,22 @@ class FuelPriceService
{ {
$stationIds = array_column($apiBatch, 'node_id'); $stationIds = array_column($apiBatch, 'node_id');
// Filter to stations that exist in the stations table — prevents FK
// violations when the API surfaces a station before the next metadata
// refresh picks it up.
$knownStationIds = array_flip(
Station::whereIn('node_id', $stationIds)->pluck('node_id')->all(),
);
$unknown = array_diff($stationIds, array_keys($knownStationIds));
if ($unknown !== []) {
Log::info('FuelPriceService: skipped prices for unknown stations', [
'count' => count($unknown),
]);
}
// Load current prices for all stations in this batch in one query // Load current prices for all stations in this batch in one query
$currentPrices = StationPriceCurrent::whereIn('station_id', $stationIds) $currentPrices = StationPriceCurrent::whereIn('station_id', array_keys($knownStationIds))
->get() ->get()
->groupBy('station_id') ->groupBy('station_id')
->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value)); ->map(fn ($rows) => $rows->keyBy(fn ($r) => $r->fuel_type->value));
@@ -227,9 +294,17 @@ class FuelPriceService
$upsertRows = []; $upsertRows = [];
foreach ($apiBatch as $station) { foreach ($apiBatch as $station) {
$stationId = $station['node_id']; $stationId = $station['node_id'] ?? null;
if ($stationId === null || ! isset($knownStationIds[$stationId])) {
continue;
}
foreach ($station['fuel_prices'] ?? [] as $priceData) { foreach ($station['fuel_prices'] ?? [] as $priceData) {
if (! isset($priceData['fuel_type'], $priceData['price'], $priceData['price_last_updated'], $priceData['price_change_effective_timestamp'])) {
continue;
}
try { try {
$fuelType = FuelType::fromApiValue($priceData['fuel_type']); $fuelType = FuelType::fromApiValue($priceData['fuel_type']);
} catch (ValueError) { } catch (ValueError) {

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Services;
/**
* Builds canonical haversine SQL fragments for distance and within-radius
* filtering. Centralises the float-clamping (GREATEST/LEAST) and the column
* naming convention used across prediction and station search queries.
*
* Assumes the joined/queried table exposes columns `lat` and `lng`.
*/
final class HaversineQuery
{
private const string DISTANCE_KM_SQL =
'(6371 * acos(GREATEST(-1.0, LEAST(1.0, '
.'cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) '
.'+ sin(radians(?)) * sin(radians(lat))))))';
/**
* Bare distance-in-km expression. Caller adds aliasing or comparison.
*
* @return array{0: string, 1: array{float, float, float}}
*/
public static function distanceKm(float $lat, float $lng): array
{
return [self::DISTANCE_KM_SQL, [$lat, $lng, $lat]];
}
/**
* `<= {km}` predicate suitable for whereRaw. The radius is embedded as a
* numeric literal because PDO + SQLite's whereRaw binds floats as strings
* by default, which breaks numeric comparison against the haversine
* expression. The `float` parameter is type-checked and not user input.
*
* @return array{0: string, 1: array{float, float, float}}
*/
public static function withinKm(float $lat, float $lng, float $km): array
{
return [self::DISTANCE_KM_SQL.' <= '.sprintf('%F', $km), [$lat, $lng, $lat]];
}
}

View File

@@ -0,0 +1,99 @@
<?php
namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
abstract class AbstractLlmPredictionProvider implements OilPredictionProvider
{
protected const int LLM_MAX_CONFIDENCE = 85;
public function __construct(
protected readonly ApiLogger $apiLogger,
) {}
/**
* Default flow: gate on API key, call the provider, normalise the payload
* to a PricePrediction. Subclasses with multi-phase flows (e.g. Anthropic
* web-search) override `predict()` directly and reuse the helper methods.
*/
public function predict(Collection $prices): ?PricePrediction
{
$apiKey = $this->apiKey();
if ($apiKey === null) {
return null;
}
try {
$payload = $this->callProvider($apiKey, $this->buildPriceList($prices));
return $payload === null ? null : $this->buildPrediction($payload);
} catch (Throwable $e) {
Log::error(static::class.': predict failed', ['error' => $e->getMessage()]);
return null;
}
}
/** Returns the configured API key or null if not set. */
abstract protected function apiKey(): ?string;
/**
* Make the provider HTTP call and return the normalised payload, or null
* on failure (already logged by the implementer).
*
* @return array{direction: string, confidence: int, reasoning: string}|null
*/
abstract protected function callProvider(string $apiKey, string $priceList): ?array;
/** @param Collection<int, BrentPrice> $prices */
protected function buildPriceList(Collection $prices): string
{
return $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
}
/** @param array{direction: string, confidence: int, reasoning: string} $input */
protected function buildPrediction(array $input, PredictionSource $source = PredictionSource::Llm): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error(static::class.': invalid direction', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) ($input['confidence'] ?? 0), self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'] ?? '',
'generated_at' => now(),
]);
}
protected function defaultPrompt(string $priceList): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Respond with direction (rising, falling, or flat), a confidence score (085),
and a one-sentence reasoning.
PROMPT;
}
}

View File

@@ -3,31 +3,23 @@
namespace App\Services\LlmPrediction; namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource; use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction; use App\Models\PricePrediction;
use App\Services\ApiLogger; use App\Services\Ewma;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable; use Throwable;
class AnthropicPredictionProvider implements OilPredictionProvider class AnthropicPredictionProvider extends AbstractLlmPredictionProvider
{ {
private const int LLM_MAX_CONFIDENCE = 85;
private const float EWMA_ALPHA = 0.3;
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
/** /**
* Tries web-search-enriched prediction first, falls back to basic tool use. * Tries web-search-enriched prediction first, falls back to basic tool use.
* Overrides the parent flow because Anthropic uses two phases (web search
* loop + forced tool call) and selects the source dynamically.
*/ */
public function predict(Collection $prices): ?PricePrediction public function predict(Collection $prices): ?PricePrediction
{ {
if (! config('services.anthropic.api_key')) { if ($this->apiKey() === null) {
return null; return null;
} }
@@ -36,10 +28,21 @@ class AnthropicPredictionProvider implements OilPredictionProvider
return $prediction ?? $this->predictBasic($prices); return $prediction ?? $this->predictBasic($prices);
} }
protected function apiKey(): ?string
{
return config('services.anthropic.api_key');
}
/** {@inheritDoc} */
protected function callProvider(string $apiKey, string $priceList): ?array
{
return null;
}
/** /**
* Multi-turn web search phase, then a forced submit_prediction call. * Multi-turn web search phase, then a forced submit_prediction call.
* Phase 1: Let the model search for recent oil/geopolitical news (pause_turn loop). * Phase 1: let the model search for recent oil/geopolitical news.
* Phase 2: Force submit_prediction with the full conversation context. * Phase 2: force submit_prediction with the full conversation context.
*/ */
private function predictWithWebContext(Collection $prices): ?PricePrediction private function predictWithWebContext(Collection $prices): ?PricePrediction
{ {
@@ -47,7 +50,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$url = 'https://api.anthropic.com/v1/messages'; $url = 'https://api.anthropic.com/v1/messages';
try { try {
// Phase 1: web search loop
for ($i = 0, $response = null; $i < 5; $i++) { for ($i = 0, $response = null; $i < 5; $i++) {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30) $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30)
->withHeaders($this->headers()) ->withHeaders($this->headers())
@@ -59,7 +61,7 @@ class AnthropicPredictionProvider implements OilPredictionProvider
])); ]));
if (! $response->successful()) { if (! $response->successful()) {
Log::error('AnthropicPredictionProvider: context search request failed', ['status' => $response->status()]); Log::error(self::class.': context search request failed', ['status' => $response->status()]);
return null; return null;
} }
@@ -71,7 +73,6 @@ class AnthropicPredictionProvider implements OilPredictionProvider
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; $messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
} }
// Phase 2: forced submit with full context
$messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; $messages[] = ['role' => 'assistant', 'content' => $response->json('content')];
$messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.']; $messages[] = ['role' => 'user', 'content' => 'Now submit your prediction using the submit_prediction tool.'];
@@ -86,22 +87,61 @@ class AnthropicPredictionProvider implements OilPredictionProvider
])); ]));
if (! $submitResponse->successful()) { if (! $submitResponse->successful()) {
Log::error('AnthropicPredictionProvider: context submit request failed', ['status' => $submitResponse->status()]); Log::error(self::class.': context submit request failed', ['status' => $submitResponse->status()]);
return null; return null;
} }
$input = $this->extractToolInput($submitResponse->json('content') ?? []); $input = $this->extractToolInput($submitResponse->json('content') ?? []);
if ($input === null) { return $input === null
Log::error('AnthropicPredictionProvider: no tool_use block in context submit response'); ? null
: $this->buildPrediction($input, PredictionSource::LlmWithContext);
} catch (Throwable $e) {
Log::error(self::class.': predictWithWebContext failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = Ewma::compute($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = Ewma::compute($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = Ewma::compute($chronological->pluck('price_usd')->values()->all());
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($this->buildPriceList($prices), $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error(self::class.': basic request failed', ['status' => $response->status()]);
return null; return null;
} }
return $this->buildPrediction($input, PredictionSource::LlmWithContext); $input = $this->extractToolInput($response->json('content') ?? []);
return $input === null ? null : $this->buildPrediction($input);
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictWithWebContext failed', ['error' => $e->getMessage()]); Log::error(self::class.': predictBasic failed', ['error' => $e->getMessage()]);
return null; return null;
} }
@@ -126,18 +166,29 @@ class AnthropicPredictionProvider implements OilPredictionProvider
PROMPT; PROMPT;
} }
private function buildPriceList(Collection $prices): string private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{ {
return $prices->sortBy('date') return <<<PROMPT
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd) You are analyzing Brent crude oil price data for a UK fuel price alert service.
->implode("\n"); Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
} }
/** @return array<string, string> */ /** @return array<string, string> */
private function headers(): array private function headers(): array
{ {
return [ return [
'x-api-key' => config('services.anthropic.api_key'), 'x-api-key' => $this->apiKey(),
'anthropic-version' => '2023-06-01', 'anthropic-version' => '2023-06-01',
]; ];
} }
@@ -177,108 +228,4 @@ class AnthropicPredictionProvider implements OilPredictionProvider
return $block['input'] ?? null; return $block['input'] ?? null;
} }
/** @param array{direction: string, confidence: int, reasoning: string} $input */
private function buildPrediction(array $input, PredictionSource $source): ?PricePrediction
{
$direction = TrendDirection::tryFrom($input['direction'] ?? '');
if ($direction === null) {
Log::error('AnthropicPredictionProvider: invalid direction in tool input', ['input' => $input]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => $source,
'direction' => $direction,
'confidence' => min((int) $input['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $input['reasoning'],
'generated_at' => now(),
]);
}
/**
* Single-turn prediction using a forced submit_prediction tool call.
* Guarantees structured output no JSON parsing needed.
*/
private function predictBasic(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date');
$ewma3 = $this->computeEwma($chronological->take(-3)->pluck('price_usd')->values()->all());
$ewma7 = $this->computeEwma($chronological->take(-7)->pluck('price_usd')->values()->all());
$ewma14 = $this->computeEwma($chronological->pluck('price_usd')->values()->all());
$priceList = $this->buildPriceList($prices);
$url = 'https://api.anthropic.com/v1/messages';
try {
$response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(15)
->withHeaders($this->headers())
->post($url, [
'model' => config('services.anthropic.model', 'claude-haiku-4-5-20251001'),
'max_tokens' => 256,
'tools' => [$this->submitPredictionTool()],
'tool_choice' => ['type' => 'tool', 'name' => 'submit_prediction'],
'messages' => [[
'role' => 'user',
'content' => $this->basicPrompt($priceList, $ewma3, $ewma7, $ewma14),
]],
]));
if (! $response->successful()) {
Log::error('AnthropicPredictionProvider: basic request failed', ['status' => $response->status()]);
return null;
}
$input = $this->extractToolInput($response->json('content') ?? []);
if ($input === null) {
Log::error('AnthropicPredictionProvider: no tool_use block in basic response');
return null;
}
return $this->buildPrediction($input, PredictionSource::Llm);
} catch (Throwable $e) {
Log::error('AnthropicPredictionProvider: predictBasic failed', ['error' => $e->getMessage()]);
return null;
}
}
/**
* @param float[] $prices Chronological order (oldest first)
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
private function basicPrompt(string $priceList, float $ewma3, float $ewma7, float $ewma14): string
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel):
{$priceList}
Pre-computed indicators:
- 3-day EWMA: \${$ewma3}
- 7-day EWMA: \${$ewma7}
- 14-day EWMA: \${$ewma14}
Use the submit_prediction tool to submit your answer.
PROMPT;
}
} }

View File

@@ -2,110 +2,59 @@
namespace App\Services\LlmPrediction; namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable;
class GeminiPredictionProvider implements OilPredictionProvider class GeminiPredictionProvider extends AbstractLlmPredictionProvider
{ {
private const int LLM_MAX_CONFIDENCE = 85; protected function apiKey(): ?string
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
{ {
if (! config('services.gemini.api_key')) { return config('services.gemini.api_key');
return null; }
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
protected function callProvider(string $apiKey, string $priceList): ?array
{
$model = config('services.gemini.model', 'gemini-2.0-flash'); $model = config('services.gemini.model', 'gemini-2.0-flash');
$url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent"; $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
try { $response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15)
$response = $this->apiLogger->send('gemini', 'POST', $url, fn () => Http::timeout(15) ->withQueryParameters(['key' => $apiKey])
->withQueryParameters(['key' => config('services.gemini.api_key')]) ->post($url, [
->post($url, [ 'contents' => [[
'contents' => [[ 'parts' => [['text' => $this->defaultPrompt($priceList)]],
'parts' => [['text' => $this->prompt($priceList)]], ]],
]], 'generationConfig' => [
'generationConfig' => [ 'responseMimeType' => 'application/json',
'responseMimeType' => 'application/json', 'responseSchema' => [
'responseSchema' => [ 'type' => 'OBJECT',
'type' => 'OBJECT', 'properties' => [
'properties' => [ 'direction' => [
'direction' => [ 'type' => 'STRING',
'type' => 'STRING', 'enum' => ['rising', 'falling', 'flat'],
'enum' => ['rising', 'falling', 'flat'],
],
'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
], ],
'required' => ['direction', 'confidence', 'reasoning'], 'confidence' => ['type' => 'INTEGER'],
'reasoning' => ['type' => 'STRING'],
], ],
'required' => ['direction', 'confidence', 'reasoning'],
], ],
])); ],
]));
if (! $response->successful()) { if (! $response->successful()) {
Log::error('GeminiPredictionProvider: request failed', ['status' => $response->status()]); Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$text = $response->json('candidates.0.content.parts.0.text') ?? '';
$data = json_decode($text, true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('GeminiPredictionProvider: unexpected response format', ['text' => $text]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
if ($direction === null) {
Log::error('GeminiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('GeminiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
return null; return null;
} }
}
private function prompt(string $priceList): string $text = $response->json('candidates.0.content.parts.0.text') ?? '';
{ $data = json_decode($text, true);
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel): if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
{$priceList} Log::error(self::class.': unexpected response format', ['text' => $text]);
Respond with direction (rising, falling, or flat), a confidence score (085), return null;
and a one-sentence reasoning. }
PROMPT;
return $data;
} }
} }

View File

@@ -2,112 +2,61 @@
namespace App\Services\LlmPrediction; namespace App\Services\LlmPrediction;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\ApiLogger;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Throwable;
class OpenAiPredictionProvider implements OilPredictionProvider class OpenAiPredictionProvider extends AbstractLlmPredictionProvider
{ {
private const int LLM_MAX_CONFIDENCE = 85; protected function apiKey(): ?string
public function __construct(
private readonly ApiLogger $apiLogger,
) {}
public function predict(Collection $prices): ?PricePrediction
{ {
if (! config('services.openai.api_key')) { return config('services.openai.api_key');
return null; }
}
$priceList = $prices->sortBy('date')
->map(fn (BrentPrice $p) => $p->date->toDateString().': $'.$p->price_usd)
->implode("\n");
protected function callProvider(string $apiKey, string $priceList): ?array
{
$url = 'https://api.openai.com/v1/chat/completions'; $url = 'https://api.openai.com/v1/chat/completions';
try { $response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15)
$response = $this->apiLogger->send('openai', 'POST', $url, fn () => Http::timeout(15) ->withToken($apiKey)
->withToken(config('services.openai.api_key')) ->post($url, [
->post($url, [ 'model' => config('services.openai.model', 'gpt-4o-mini'),
'model' => config('services.openai.model', 'gpt-4o-mini'), 'response_format' => [
'response_format' => [ 'type' => 'json_schema',
'type' => 'json_schema', 'json_schema' => [
'json_schema' => [ 'name' => 'oil_prediction',
'name' => 'oil_prediction', 'strict' => true,
'strict' => true, 'schema' => [
'schema' => [ 'type' => 'object',
'type' => 'object', 'properties' => [
'properties' => [ 'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']],
'direction' => ['type' => 'string', 'enum' => ['rising', 'falling', 'flat']], 'confidence' => ['type' => 'integer'],
'confidence' => ['type' => 'integer'], 'reasoning' => ['type' => 'string'],
'reasoning' => ['type' => 'string'],
],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
], ],
'required' => ['direction', 'confidence', 'reasoning'],
'additionalProperties' => false,
], ],
], ],
'messages' => [[ ],
'role' => 'user', 'messages' => [[
'content' => $this->prompt($priceList), 'role' => 'user',
]], 'content' => $this->defaultPrompt($priceList),
])); ]],
]));
if (! $response->successful()) { if (! $response->successful()) {
Log::error('OpenAiPredictionProvider: request failed', ['status' => $response->status()]); Log::error(self::class.': request failed', ['status' => $response->status()]);
return null;
}
$data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
Log::error('OpenAiPredictionProvider: unexpected response format', ['data' => $data]);
return null;
}
$direction = TrendDirection::tryFrom($data['direction']);
if ($direction === null) {
Log::error('OpenAiPredictionProvider: invalid direction', ['direction' => $data['direction']]);
return null;
}
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Llm,
'direction' => $direction,
'confidence' => min((int) $data['confidence'], self::LLM_MAX_CONFIDENCE),
'reasoning' => $data['reasoning'],
'generated_at' => now(),
]);
} catch (Throwable $e) {
Log::error('OpenAiPredictionProvider: predict failed', ['error' => $e->getMessage()]);
return null; return null;
} }
}
private function prompt(string $priceList): string $data = json_decode($response->json('choices.0.message.content') ?? '{}', true);
{
return <<<PROMPT
You are analyzing Brent crude oil price data for a UK fuel price alert service.
Predict the short-term direction over the next 35 days.
Recent Brent crude prices (USD/barrel): if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) {
{$priceList} Log::error(self::class.': unexpected response format', ['data' => $data]);
Respond with direction (rising, falling, or flat), a confidence score (085), return null;
and a one-sentence reasoning. }
PROMPT;
return $data;
} }
} }

View File

@@ -4,16 +4,31 @@ namespace App\Services;
use App\Enums\FuelType; use App\Enums\FuelType;
use App\Models\StationPriceCurrent; use App\Models\StationPriceCurrent;
use App\Services\Prediction\Signals\BrandBehaviourSignal;
use App\Services\Prediction\Signals\DayOfWeekSignal;
use App\Services\Prediction\Signals\OilSignal;
use App\Services\Prediction\Signals\RegionalMomentumSignal;
use App\Services\Prediction\Signals\SignalContext;
use App\Services\Prediction\Signals\StickinessSignal;
use App\Services\Prediction\Signals\TrendSignal;
use Carbon\CarbonInterface;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class NationalFuelPredictionService class NationalFuelPredictionService
{ {
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3; private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const int PREDICTION_HORIZON_DAYS = 7; private const int PREDICTION_HORIZON_DAYS = 7;
public function __construct(
private readonly TrendSignal $trendSignal,
private readonly DayOfWeekSignal $dayOfWeekSignal,
private readonly BrandBehaviourSignal $brandBehaviourSignal,
private readonly StickinessSignal $stickinessSignal,
private readonly RegionalMomentumSignal $regionalMomentumSignal,
private readonly OilSignal $oilSignal,
) {}
/** /**
* @return array{ * @return array{
* fuel_type: string, * fuel_type: string,
@@ -34,19 +49,19 @@ class NationalFuelPredictionService
{ {
$fuelType = FuelType::E10; $fuelType = FuelType::E10;
$hasCoordinates = $lat !== null && $lng !== null; $hasCoordinates = $lat !== null && $lng !== null;
$context = new SignalContext($fuelType, $lat, $lng);
$currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng); $currentAvg = $this->getCurrentAverage($fuelType, $lat, $lng);
$trend = $this->computeTrendSignal($fuelType); $trend = $this->trendSignal->compute($context);
$dayOfWeek = $this->computeDayOfWeekSignal($fuelType); $dayOfWeek = $this->dayOfWeekSignal->compute($context);
$brandBehaviour = $this->computeBrandBehaviourSignal($fuelType); $brandBehaviour = $this->brandBehaviourSignal->compute($context);
$stickiness = $this->computeStickinessSignal($fuelType); $stickiness = $this->stickinessSignal->compute($context);
$oil = $this->oilSignal->compute($context);
$nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions'); $nationalMomentum = $this->disabledSignal('National momentum disabled for national predictions');
$regionalMomentum = $hasCoordinates $regionalMomentum = $this->regionalMomentumSignal->compute($context);
? $this->computeRegionalMomentumSignal($fuelType, $lat, $lng)
: $this->disabledSignal('No coordinates provided for regional momentum analysis');
$signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness'); $signals = compact('trend', 'dayOfWeek', 'brandBehaviour', 'nationalMomentum', 'regionalMomentum', 'stickiness', 'oil');
[$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates); [$direction, $confidenceScore] = $this->aggregateSignals($signals, $hasCoordinates);
@@ -65,6 +80,8 @@ class NationalFuelPredictionService
default => 'no_signal', default => 'no_signal',
}; };
$weeklySummary = $this->computeWeeklySummary($fuelType, $lat, $lng, $currentAvg, $slope);
return [ return [
'fuel_type' => $fuelType->value, 'fuel_type' => $fuelType->value,
'current_avg' => $currentAvg, 'current_avg' => $currentAvg,
@@ -73,10 +90,11 @@ class NationalFuelPredictionService
'confidence_score' => $confidenceScore, 'confidence_score' => $confidenceScore,
'confidence_label' => $confidenceLabel, 'confidence_label' => $confidenceLabel,
'action' => $action, 'action' => $action,
'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour), 'reasoning' => $this->buildReasoning($direction, $slope, $trend, $brandBehaviour, $dayOfWeek),
'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS, 'prediction_horizon_days' => self::PREDICTION_HORIZON_DAYS,
'region_key' => $hasCoordinates ? 'regional' : 'national', 'region_key' => $hasCoordinates ? 'regional' : 'national',
'methodology' => 'multi_signal_live_fallback', 'methodology' => 'multi_signal_live_fallback',
'weekly_summary' => $weeklySummary,
'signals' => [ 'signals' => [
'trend' => $trend, 'trend' => $trend,
'day_of_week' => $dayOfWeek, 'day_of_week' => $dayOfWeek,
@@ -84,6 +102,7 @@ class NationalFuelPredictionService
'national_momentum' => $nationalMomentum, 'national_momentum' => $nationalMomentum,
'regional_momentum' => $regionalMomentum, 'regional_momentum' => $regionalMomentum,
'price_stickiness' => $stickiness, 'price_stickiness' => $stickiness,
'oil' => $oil,
], ],
]; ];
} }
@@ -91,10 +110,12 @@ class NationalFuelPredictionService
private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float private function getCurrentAverage(FuelType $fuelType, ?float $lat, ?float $lng): float
{ {
if ($lat !== null && $lng !== null) { if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$avg = DB::table('station_prices_current') $avg = DB::table('station_prices_current')
->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id') ->join('stations', 'station_prices_current.station_id', '=', 'stations.node_id')
->where('station_prices_current.fuel_type', $fuelType->value) ->where('station_prices_current.fuel_type', $fuelType->value)
->whereRaw('(6371 * acos(LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))))) <= 50', [$lat, $lng, $lat]) ->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices_current.price_pence'); ->avg('station_prices_current.price_pence');
if ($avg !== null) { if ($avg !== null) {
@@ -107,285 +128,6 @@ class NationalFuelPredictionService
return $avg !== null ? round((float) $avg / 100, 1) : 0.0; return $avg !== null ? round((float) $avg / 100, 1) : 0.0;
} }
/**
* Linear regression on daily national average prices.
* Tries 5-day lookback first; falls back to 14-day if < threshold.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float}
*/
private function computeTrendSignal(FuelType $fuelType): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / 2.0) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
/**
* Compare today's average price against the per-weekday average over 90 days.
* Requires 56+ days of history to activate.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeDayOfWeekSignal(FuelType $fuelType): array
{
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$dowExpr = $isSqlite
? "(CAST(strftime('%w', price_effective_at) AS INTEGER) + 1)"
: 'DAYOFWEEK(price_effective_at)';
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < 56) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need 56)");
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$cheapestDayName = $dayNames[($cheapestDow - 1) % 7] ?? 'Unknown';
$weekRange = round(($dowAverages->max() - $dowAverages->min()) / 100, 1);
$tomorrowDelta = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
return [
'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction,
'detail' => "Cheapest day: {$cheapestDayName}. Weekly range: {$weekRange}p. Tomorrow typically {$tomorrowDelta}p less than today.",
'data_points' => $uniqueDays,
'enabled' => true,
];
}
/**
* Compare supermarket vs non-supermarket 7-day price trend.
* Detects divergence where one group has moved but the other hasn't yet.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeBrandBehaviourSignal(FuelType $fuelType): array
{
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
/**
* Average hold duration (days between price changes) as a confidence modifier.
* Requires 30+ days of history. Returns a score between -0.1 and +0.1.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeStickinessSignal(FuelType $fuelType): array
{
$isSqlite = DB::connection()->getDriverName() === 'sqlite';
$diffExpr = $isSqlite
? 'CAST((julianday(MAX(price_effective_at)) - julianday(MIN(price_effective_at))) AS INTEGER)'
: 'DATEDIFF(MAX(price_effective_at), MIN(price_effective_at))';
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
/**
* Placeholder for regional momentum signal (requires lat/lng).
* Compares local station prices vs national average trend.
*
* @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool}
*/
private function computeRegionalMomentumSignal(FuelType $fuelType, float $lat, float $lng): array
{
// Regional momentum: compare trend of stations within 50km vs national trend
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
->whereRaw('(6371 * acos(CASE WHEN (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) > 1.0 THEN 1.0 ELSE (cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat))) END)) <= 50', [$lat, $lng, $lat, $lat, $lng, $lat])
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regionalRegression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regionalRegression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regionalRegression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regionalRegression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regionalRegression['slope'], 2).'p/day (R²='.round($regionalRegression['r_squared'], 2).')',
'data_points' => $rows->count(),
'enabled' => true,
];
}
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */ /** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool} */
private function disabledSignal(string $detail): array private function disabledSignal(string $detail): array
{ {
@@ -400,46 +142,64 @@ class NationalFuelPredictionService
} }
/** /**
* Weighted aggregate of enabled signals. * Aggregate enabled signals into a final direction + confidence score.
* Returns [direction string, confidence score 0-100].
* *
* @param array<string, array{score: float, confidence: float, enabled: bool}> $signals * Direction: weighted vote across signals that have a non-stable direction.
* stable signals do NOT dilute the directional vote.
*
* Confidence: weighted average of enabled signals' own confidence values,
* multiplied by an agreement coefficient (0..1) measuring how the signals
* line up with the chosen direction.
*
* @param array<string, array{score: float, confidence: float, direction: string, enabled: bool}> $signals
* @return array{0: string, 1: float} * @return array{0: string, 1: float}
*/ */
private function aggregateSignals(array $signals, bool $hasCoordinates = false): array private function aggregateSignals(array $signals, bool $hasCoordinates = false): array
{ {
$weights = $hasCoordinates $weights = $hasCoordinates
? [ ? [
'regionalMomentum' => 0.50, 'regionalMomentum' => 0.35,
'trend' => 0.20, 'oil' => 0.20,
'trend' => 0.15,
'dayOfWeek' => 0.15, 'dayOfWeek' => 0.15,
'brandBehaviour' => 0.10, 'brandBehaviour' => 0.10,
'stickiness' => 0.05, 'stickiness' => 0.05,
] ]
: [ : [
'trend' => 0.45, 'trend' => 0.30,
'oil' => 0.25,
'dayOfWeek' => 0.20, 'dayOfWeek' => 0.20,
'brandBehaviour' => 0.25, 'brandBehaviour' => 0.15,
'stickiness' => 0.10, 'stickiness' => 0.10,
]; ];
$weightedSum = 0.0; $directionalScoreSum = 0.0;
$totalWeight = 0.0; $directionalWeightSum = 0.0;
$confidenceWeightedSum = 0.0;
$totalEnabledWeight = 0.0;
foreach ($weights as $key => $weight) { foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null; $signal = $signals[$key] ?? null;
if ($signal && $signal['enabled']) { if (! $signal || ! $signal['enabled']) {
$weightedSum += $signal['score'] * $signal['confidence'] * $weight; continue;
$totalWeight += $weight; }
$totalEnabledWeight += $weight;
$confidenceWeightedSum += $signal['confidence'] * $weight;
if ($signal['direction'] !== 'stable') {
$directionalScoreSum += $signal['score'] * $signal['confidence'] * $weight;
$directionalWeightSum += $weight;
} }
} }
if ($totalWeight < 0.01) { if ($totalEnabledWeight < 0.01) {
return ['stable', 0.0]; return ['stable', 0.0];
} }
$normalised = $weightedSum / $totalWeight; $normalised = $directionalWeightSum > 0.01
$confidenceScore = round(min(100.0, abs($normalised) * 100), 1); ? $directionalScoreSum / $directionalWeightSum
: 0.0;
$direction = match (true) { $direction = match (true) {
$normalised >= 0.1 => 'up', $normalised >= 0.1 => 'up',
@@ -447,51 +207,185 @@ class NationalFuelPredictionService
default => 'stable', default => 'stable',
}; };
$avgConfidence = $confidenceWeightedSum / $totalEnabledWeight;
$agreement = $this->computeAgreement($signals, $weights, $direction);
$confidenceScore = round(min(100.0, $avgConfidence * $agreement * 100), 1);
return [$direction, $confidenceScore]; return [$direction, $confidenceScore];
} }
/** /**
* Least-squares linear regression. * How well the enabled signals line up with the chosen direction.
* x is the array index (day number), y is the price value. * - aligned signal: full credit (signal_confidence × weight)
* - one side stable, other directional: half credit
* - opposing signals: no credit
* *
* @param float[] $values * Range: 0 (full disagreement) 1 (unanimous).
* @return array{slope: float, r_squared: float} *
* @param array<string, array{confidence: float, direction: string, enabled: bool}> $signals
* @param array<string, float> $weights
*/ */
private function linearRegression(array $values): array private function computeAgreement(array $signals, array $weights, string $finalDirection): float
{ {
$n = count($values); $finalDir = match ($finalDirection) {
if ($n < 2) { 'up' => 1,
return ['slope' => 0.0, 'r_squared' => 0.0]; 'down' => -1,
default => 0,
};
$credit = 0.0;
$maxCredit = 0.0;
foreach ($weights as $key => $weight) {
$signal = $signals[$key] ?? null;
if (! $signal || ! $signal['enabled']) {
continue;
}
$maxCredit += $signal['confidence'] * $weight;
$signalDir = match ($signal['direction']) {
'up' => 1,
'down' => -1,
default => 0,
};
if ($signalDir === $finalDir) {
$credit += $signal['confidence'] * $weight;
} elseif ($signalDir === 0 || $finalDir === 0) {
$credit += 0.5 * $signal['confidence'] * $weight;
}
} }
$xMean = ($n - 1) / 2.0; return $maxCredit > 0.0 ? $credit / $maxCredit : 0.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
} }
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour): string /**
* Yesterday / today / tomorrow snapshot + last-7-days series.
* Regional (50km) when coordinates are given, with national fallback when
* regional data is empty.
*
* @return array{
* yesterday_avg: ?float,
* today_avg: float,
* tomorrow_estimated_avg: ?float,
* yesterday_today_delta_pence: ?float,
* last_7_days_series: array<int, array{date: string, avg: float}>,
* last_7_days_change_pence: ?float,
* cheapest_day: ?array{date: string, avg: float},
* priciest_day: ?array{date: string, avg: float},
* is_regional: bool
* }
*/
private function computeWeeklySummary(FuelType $fuelType, ?float $lat, ?float $lng, float $todayAvg, float $slope): array
{
$yesterdayAvg = $this->getDailyAverage($fuelType, now()->subDay(), $lat, $lng);
[$series, $usedRegional] = $this->getDailySeries($fuelType, 7, $lat, $lng);
$tomorrowEstimated = $todayAvg > 0.0 ? round($todayAvg + $slope, 1) : null;
$yesterdayTodayDelta = $yesterdayAvg !== null ? round($todayAvg - $yesterdayAvg, 1) : null;
$cheapestDay = null;
$priciestDay = null;
$weekChange = null;
if (count($series) >= 2) {
$byPrice = $series;
usort($byPrice, fn ($a, $b) => $a['avg'] <=> $b['avg']);
$cheapestDay = $byPrice[0];
$priciestDay = $byPrice[count($byPrice) - 1];
$weekChange = round(end($series)['avg'] - $series[0]['avg'], 1);
}
return [
'yesterday_avg' => $yesterdayAvg,
'today_avg' => $todayAvg,
'tomorrow_estimated_avg' => $tomorrowEstimated,
'yesterday_today_delta_pence' => $yesterdayTodayDelta,
'last_7_days_series' => $series,
'last_7_days_change_pence' => $weekChange,
'cheapest_day' => $cheapestDay,
'priciest_day' => $priciestDay,
'is_regional' => $usedRegional,
];
}
private function getDailyAverage(FuelType $fuelType, CarbonInterface $date, ?float $lat, ?float $lng): ?float
{
$dateString = $date->toDateString();
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$regional = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->whereDate('station_prices.price_effective_at', $dateString)
->whereRaw($radiusSql, $radiusBindings)
->avg('station_prices.price_pence');
if ($regional !== null) {
return round((float) $regional / 100, 1);
}
}
$national = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->whereDate('price_effective_at', $dateString)
->avg('price_pence');
return $national !== null ? round((float) $national / 100, 1) : null;
}
/**
* @return array{0: array<int, array{date: string, avg: float}>, 1: bool}
*/
private function getDailySeries(FuelType $fuelType, int $days, ?float $lat, ?float $lng): array
{
$rows = collect();
$usedRegional = false;
if ($lat !== null && $lng !== null) {
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($lat, $lng, 50);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays($days)->startOfDay())
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
$usedRegional = $rows->isNotEmpty();
}
if ($rows->isEmpty()) {
$rows = DB::table('station_prices')
->where('fuel_type', $fuelType->value)
->where('price_effective_at', '>=', now()->subDays($days)->startOfDay())
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
}
$series = $rows->map(fn ($r): array => [
'date' => (string) $r->day,
'avg' => round((float) $r->avg_price / 100, 1),
])->values()->all();
return [$series, $usedRegional];
}
/**
* @param array{enabled: bool, detail: string, direction: string} $trend
* @param array{enabled: bool, detail: string, direction: string} $brandBehaviour
* @param array{enabled: bool, detail: string, direction: string} $dayOfWeek
*/
private function buildReasoning(string $direction, float $slope, array $trend, array $brandBehaviour, array $dayOfWeek): string
{ {
$parts = []; $parts = [];
@@ -503,8 +397,16 @@ class NationalFuelPredictionService
$parts[] = $brandBehaviour['detail']; $parts[] = $brandBehaviour['detail'];
} }
if ($dayOfWeek['enabled']) {
$parts[] = $dayOfWeek['detail'];
}
if (empty($parts)) { if (empty($parts)) {
return 'No clear pattern — fill up at the cheapest station near you now.'; return match ($direction) {
'up' => 'Mild upward signals — top up soon if you\'re nearby.',
'down' => 'Mild downward signals — wait a day or two if your tank can hold.',
default => 'No clear pattern — fill up at the cheapest station near you now.',
};
} }
return implode(' ', $parts); return implode(' ', $parts);

View File

@@ -1,190 +0,0 @@
<?php
namespace App\Services;
use App\Enums\PredictionSource;
use App\Enums\TrendDirection;
use App\Models\BrentPrice;
use App\Models\PricePrediction;
use App\Services\LlmPrediction\OilPredictionProvider;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Throwable;
class OilPriceService
{
/**
* Decay factor for EWMA. Higher = more weight on recent prices.
*/
private const float EWMA_ALPHA = 0.3;
/**
* Minimum % change in EWMA to be considered rising/falling.
*/
private const float EWMA_THRESHOLD_PCT = 1.5;
/**
* EWMA confidence is capped lower than LLM it's a simpler model.
*/
private const int EWMA_MAX_CONFIDENCE = 65;
/**
* Minimum price rows needed before EWMA is meaningful.
*/
private const int EWMA_MIN_ROWS = 14;
public function __construct(
private readonly ApiLogger $apiLogger,
private readonly OilPredictionProvider $provider,
) {}
/**
* Fetch the last 30 days of Brent crude prices from FRED and store them.
*/
public function fetchBrentPrices(): void
{
$url = 'https://api.stlouisfed.org/fred/series/observations';
try {
$response = $this->apiLogger->send('fred', 'GET', $url, fn () => Http::timeout(10)
->get($url, [
'series_id' => 'DCOILBRENTEU',
'api_key' => config('services.fred.api_key'),
'sort_order' => 'desc',
'limit' => 30,
'file_type' => 'json',
]));
if (! $response->successful()) {
Log::error('OilPriceService: FRED request failed', ['status' => $response->status()]);
return;
}
$rows = collect($response->json('observations') ?? [])
->filter(fn (array $obs) => $obs['value'] !== '.') // FRED uses '.' for missing data
->map(fn (array $obs) => [
'date' => $obs['date'],
'price_usd' => (float) $obs['value'],
])
->all();
if (empty($rows)) {
Log::warning('OilPriceService: no valid FRED observations returned');
return;
}
BrentPrice::upsert($rows, ['date'], ['price_usd']);
} catch (Throwable $e) {
Log::error('OilPriceService: fetchBrentPrices failed', ['error' => $e->getMessage()]);
}
}
/**
* Generate predictions from all available sources and store each one.
* EWMA always runs. LLM provider runs and returns null if not configured.
* Returns the highest-confidence prediction (LLM preferred over EWMA).
*/
public function generatePrediction(): ?PricePrediction
{
$prices = BrentPrice::orderBy('date', 'desc')->limit(30)->get();
if ($prices->count() < self::EWMA_MIN_ROWS) {
Log::warning('OilPriceService: not enough price data to generate prediction', [
'rows' => $prices->count(),
]);
return null;
}
$ewma = $this->generateEwmaPrediction($prices);
if ($ewma !== null) {
PricePrediction::create($ewma->toArray());
}
$llm = $this->provider->predict($prices);
if ($llm !== null) {
PricePrediction::create($llm->toArray());
}
return $llm ?? $ewma;
}
/**
* Option A EWMA-based trend extrapolation. Used as fallback when LLM is unavailable.
* Compares the 3-day EWMA against the 7-day EWMA to detect direction.
*/
public function generateEwmaPrediction(Collection $prices): ?PricePrediction
{
$chronological = $prices->sortBy('date')->pluck('price_usd')->values()->all();
if (count($chronological) < self::EWMA_MIN_ROWS) {
return null;
}
$ewma3 = $this->computeEwma(array_slice($chronological, -3));
$ewma7 = $this->computeEwma(array_slice($chronological, -7));
$changePct = (($ewma3 - $ewma7) / $ewma7) * 100;
[$direction, $confidence] = match (true) {
$changePct >= self::EWMA_THRESHOLD_PCT => [
TrendDirection::Rising,
$this->ewmaConfidence($changePct),
],
$changePct <= -self::EWMA_THRESHOLD_PCT => [
TrendDirection::Falling,
$this->ewmaConfidence(abs($changePct)),
],
default => [TrendDirection::Flat, 50],
};
$reasoning = sprintf(
'3-day EWMA ($%.2f) vs 7-day EWMA ($%.2f): %.2f%% %s.',
$ewma3,
$ewma7,
abs($changePct),
$direction === TrendDirection::Flat ? 'difference (flat)' : $direction->value,
);
return new PricePrediction([
'predicted_for' => now()->toDateString(),
'source' => PredictionSource::Ewma,
'direction' => $direction,
'confidence' => $confidence,
'reasoning' => $reasoning,
'generated_at' => now(),
]);
}
/**
* Compute Exponential Weighted Moving Average for a series of prices.
*
* @param float[] $prices Chronological order (oldest first)
*/
private function computeEwma(array $prices): float
{
$ema = $prices[0];
foreach (array_slice($prices, 1) as $price) {
$ema = self::EWMA_ALPHA * $price + (1 - self::EWMA_ALPHA) * $ema;
}
return round($ema, 4);
}
/**
* Map a % change magnitude to a 0EWMA_MAX_CONFIDENCE confidence score.
* 1.5% ~30, 3% ~50, 5%+ 65.
*/
private function ewmaConfidence(float $changePct): int
{
$scaled = min($changePct / 5.0, 1.0) * self::EWMA_MAX_CONFIDENCE;
return (int) round(max(30, $scaled));
}
}

View File

@@ -0,0 +1,183 @@
<?php
namespace App\Services;
use App\Models\NotificationLog;
use App\Models\Plan;
use App\Models\User;
use App\Models\UserNotificationPreference;
final class PlanFeatures
{
/** @var string[] */
private const array CHANNELS = ['email', 'push', 'whatsapp', 'sms'];
private Plan $plan;
private function __construct(private readonly User $user)
{
$this->plan = Plan::resolveForUser($user);
}
public static function for(User $user): self
{
return new self($user);
}
/**
* Channels allowed for a given trigger type, filtered by:
* tier allows user has enabled daily limit not hit.
*
* @return string[]
*/
public function channelsFor(string $triggerType): array
{
$allowed = [];
foreach (self::CHANNELS as $channel) {
if (! $this->canUseChannel($channel)) {
continue;
}
if (! $this->userHasEnabledChannel($channel)) {
continue;
}
if (! $this->canSendNow($channel)) {
continue;
}
$allowed[] = $channel;
}
return $allowed;
}
/** Whether the plan allows this channel at all. */
public function canUseChannel(string $channel): bool
{
return (bool) $this->plan->{"{$channel}_enabled"};
}
/**
* Whether a notification can be sent right now on this channel.
* Checks both the plan cap and today's live count in notification_log.
*/
public function canSendNow(string $channel): bool
{
if (! $this->canUseChannel($channel)) {
return false;
}
$dailyLimit = $this->dailyLimit($channel);
// null = unlimited; 0 = blocked even though enabled
if ($dailyLimit === null) {
return true;
}
if ($dailyLimit === 0) {
return false;
}
$sentToday = NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', true)
->whereDate('created_at', today())
->count();
return $sentToday < $dailyLimit;
}
/** Whether the user can track an additional fuel type. */
public function canTrackFuelType(string $fuelType): bool
{
$limit = $this->fuelTypeLimit();
if ($limit === null) {
return true;
}
$alreadyTracking = UserNotificationPreference::where('user_id', $this->user->id)
->where('fuel_type', $fuelType)
->exists();
if ($alreadyTracking) {
return true;
}
return $this->trackedFuelTypeCount() < $limit;
}
/** Maximum fuel types allowed, or null for unlimited. */
public function fuelTypeLimit(): ?int
{
return $this->plan->max_fuel_types;
}
/** Count of distinct fuel types the user has preferences for. */
public function trackedFuelTypeCount(): int
{
return UserNotificationPreference::where('user_id', $this->user->id)
->distinct('fuel_type')
->count('fuel_type');
}
/** Generic boolean feature flag check. */
public function can(string $feature): bool
{
return (bool) ($this->plan->{$feature} ?? false);
}
/** Count of notifications missed today on a channel. */
public function missedToday(string $channel): int
{
return NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', false)
->whereDate('created_at', today())
->count();
}
/** Count of notifications missed this month on a channel. */
public function missedThisMonth(string $channel): int
{
return NotificationLog::where('user_id', $this->user->id)
->where('channel', $channel)
->where('sent', false)
->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year)
->count();
}
/** The resolved plan tier name. */
public function tier(): string
{
return $this->plan->name;
}
/** User-facing display label for the resolved tier (e.g. basic → "Daily"). */
public function displayName(): string
{
return $this->plan->displayName();
}
/** Whether the user has opted in to this channel for at least one fuel type. */
private function userHasEnabledChannel(string $channel): bool
{
return UserNotificationPreference::where('user_id', $this->user->id)
->where('channel', $channel)
->where('enabled', true)
->exists();
}
/** Per-channel daily limit. Null on email/push (no cap), int on whatsapp/sms. */
private function dailyLimit(string $channel): ?int
{
return match ($channel) {
'whatsapp' => $this->plan->whatsapp_daily_limit,
'sms' => $this->plan->sms_daily_limit,
default => null,
};
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Services; namespace App\Services;
use App\Models\Outcode;
use App\Models\Postcode;
use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -24,7 +26,16 @@ class PostcodeService
public function resolve(string $query): ?LocationResult public function resolve(string $query): ?LocationResult
{ {
$query = trim($query); $query = trim($query);
$cacheKey = 'postcode:'.strtolower(preg_replace('/\s+/', '', $query));
if ($this->isFullPostcode($query)) {
return $this->lookupLocalPostcode($query) ?? $this->lookupPostcode($query);
}
if ($this->isOutcode($query)) {
return $this->lookupLocalOutcode($query) ?? $this->lookupOutcode($query);
}
$cacheKey = 'place:'.strtolower(preg_replace('/\s+/', '', $query));
$cached = Cache::get($cacheKey); $cached = Cache::get($cacheKey);
@@ -32,11 +43,7 @@ class PostcodeService
return $cached; return $cached;
} }
$result = match (true) { $result = $this->lookupPlace($query);
$this->isFullPostcode($query) => $this->lookupPostcode($query),
$this->isOutcode($query) => $this->lookupOutcode($query),
default => $this->lookupPlace($query),
};
if ($result !== null) { if ($result !== null) {
Cache::put($cacheKey, $result, self::CACHE_TTL); Cache::put($cacheKey, $result, self::CACHE_TTL);
@@ -45,6 +52,11 @@ class PostcodeService
return $result; return $result;
} }
private function normalisePostcode(string $value): string
{
return strtoupper(preg_replace('/\s+/', '', $value));
}
private function isFullPostcode(string $query): bool private function isFullPostcode(string $query): bool
{ {
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query); return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?\s*[0-9][A-Z]{2}$/i', $query);
@@ -55,9 +67,55 @@ class PostcodeService
return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query); return (bool) preg_match('/^[A-Z]{1,2}[0-9][0-9A-Z]?$/i', $query);
} }
private function lookupLocalPostcode(string $postcode): ?LocationResult
{
$normalised = $this->normalisePostcode($postcode);
$row = Postcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $postcode,
displayName: $this->formatPostcode($normalised),
lat: $row->lat,
lng: $row->lng,
);
}
private function lookupLocalOutcode(string $outcode): ?LocationResult
{
$normalised = strtoupper(trim($outcode));
$row = Outcode::find($normalised);
if ($row === null) {
return null;
}
return new LocationResult(
query: $outcode,
displayName: $normalised,
lat: $row->lat,
lng: $row->lng,
);
}
private function formatPostcode(string $normalised): string
{
// Insert the single space before the last 3 chars ("SW1A1AA" -> "SW1A 1AA").
if (strlen($normalised) < 5) {
return $normalised;
}
return substr($normalised, 0, -3).' '.substr($normalised, -3);
}
private function lookupPostcode(string $postcode): ?LocationResult private function lookupPostcode(string $postcode): ?LocationResult
{ {
$normalised = strtoupper(preg_replace('/\s+/', '', $postcode)); $normalised = $this->normalisePostcode($postcode);
$url = self::BASE_URL.'/postcodes/'.$normalised; $url = self::BASE_URL.'/postcodes/'.$normalised;
try { try {
@@ -69,12 +127,34 @@ class PostcodeService
$data = $response->json('result'); $data = $response->json('result');
return new LocationResult( if (! is_array($data) || ! isset($data['postcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $postcode, query: $postcode,
displayName: $data['postcode'], displayName: $data['postcode'],
lat: $data['latitude'], lat: $data['latitude'],
lng: $data['longitude'], lng: $data['longitude'],
); );
try {
Postcode::updateOrCreate(
['postcode' => $normalised],
[
'outcode' => substr($normalised, 0, strlen($normalised) - 3),
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist postcode after HTTP fallback', [
'postcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('PostcodeService: postcode lookup failed', [ Log::error('PostcodeService: postcode lookup failed', [
'postcode' => $postcode, 'postcode' => $postcode,
@@ -99,12 +179,33 @@ class PostcodeService
$data = $response->json('result'); $data = $response->json('result');
return new LocationResult( if (! is_array($data) || ! isset($data['outcode'], $data['latitude'], $data['longitude'])) {
return null;
}
$result = new LocationResult(
query: $outcode, query: $outcode,
displayName: $data['outcode'], displayName: $data['outcode'],
lat: $data['latitude'], lat: $data['latitude'],
lng: $data['longitude'], lng: $data['longitude'],
); );
try {
Outcode::updateOrCreate(
['outcode' => $normalised],
[
'lat' => $data['latitude'],
'lng' => $data['longitude'],
],
);
} catch (Throwable $e) {
Log::warning('PostcodeService: failed to persist outcode after HTTP fallback', [
'outcode' => $normalised,
'error' => $e->getMessage(),
]);
}
return $result;
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error('PostcodeService: outcode lookup failed', [ Log::error('PostcodeService: outcode lookup failed', [
'outcode' => $outcode, 'outcode' => $outcode,

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services\Prediction\Signals;
abstract class AbstractSignal implements Signal
{
/** @return array{score: 0.0, confidence: 0.0, direction: 'stable', detail: string, data_points: 0, enabled: false} */
protected function disabledSignal(string $detail): array
{
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => $detail,
'data_points' => 0,
'enabled' => false,
];
}
/**
* Least-squares linear regression. x = array index, y = value.
*
* @param float[] $values
* @return array{slope: float, r_squared: float}
*/
protected function linearRegression(array $values): array
{
$n = count($values);
if ($n < 2) {
return ['slope' => 0.0, 'r_squared' => 0.0];
}
$xMean = ($n - 1) / 2.0;
$yMean = array_sum($values) / $n;
$numerator = 0.0;
$denominator = 0.0;
foreach ($values as $i => $y) {
$x = $i - $xMean;
$numerator += $x * ($y - $yMean);
$denominator += $x * $x;
}
$slope = $denominator > 0.0 ? $numerator / $denominator : 0.0;
$ssRes = 0.0;
$ssTot = 0.0;
foreach ($values as $i => $y) {
$predicted = $yMean + $slope * ($i - $xMean);
$ssRes += ($y - $predicted) ** 2;
$ssTot += ($y - $yMean) ** 2;
}
$rSquared = $ssTot > 0.0 ? max(0.0, 1.0 - ($ssRes / $ssTot)) : 0.0;
return ['slope' => $slope, 'r_squared' => $rSquared];
}
}

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class BrandBehaviourSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(7))
->selectRaw('stations.is_supermarket, DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('stations.is_supermarket', 'day')
->orderBy('day')
->get();
$supermarket = $rows->where('is_supermarket', 1)->values();
$major = $rows->where('is_supermarket', 0)->values();
if ($supermarket->count() < 2 || $major->count() < 2) {
return $this->disabledSignal('Insufficient brand data for comparison');
}
$supermarketSlope = $this->linearRegression($supermarket->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$majorSlope = $this->linearRegression($major->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all())['slope'];
$divergence = round(abs($supermarketSlope - $majorSlope) * 7, 1);
$supermarketChange = round($supermarketSlope * 7, 1);
$majorChange = round($majorSlope * 7, 1);
if ($divergence < 1.0) {
return [
'score' => 0.0,
'confidence' => 0.5,
'direction' => 'stable',
'detail' => 'Supermarkets and majors moving in sync.',
'data_points' => $rows->count(),
'enabled' => true,
];
}
$leaderChange = abs($supermarketChange) > abs($majorChange) ? $supermarketChange : $majorChange;
$direction = $leaderChange > 0 ? 'up' : 'down';
$leader = abs($supermarketChange) > abs($majorChange) ? 'Supermarkets' : 'Majors';
$follower = $leader === 'Supermarkets' ? 'majors' : 'supermarkets';
$leaderAbs = abs($leaderChange);
$followerChange = $leader === 'Supermarkets' ? abs($majorChange) : abs($supermarketChange);
return [
'score' => $direction === 'up' ? 1.0 : -1.0,
'confidence' => min(1.0, $divergence / 5.0),
'direction' => $direction,
'detail' => "{$leader} ".($leaderChange > 0 ? 'rose' : 'fell')." {$leaderAbs}p vs {$follower} {$followerChange}p (divergence: {$divergence}p). Expect {$follower} to follow.",
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class DayOfWeekSignal extends AbstractSignal
{
private const int MIN_DAYS = 21;
public function compute(SignalContext $context): array
{
$dowExpr = DbDialect::dayOfWeekExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(90))
->selectRaw("{$dowExpr} as dow, DATE(price_effective_at) as day, AVG(price_pence) as avg_price")
->groupBy('dow', 'day')
->get();
$uniqueDays = $rows->pluck('day')->unique()->count();
if ($uniqueDays < self::MIN_DAYS) {
return $this->disabledSignal("Insufficient history for day-of-week pattern ({$uniqueDays} days, need ".self::MIN_DAYS.')');
}
$dowAverages = $rows->groupBy('dow')->map(fn ($g) => $g->avg('avg_price'));
$weekAvg = $dowAverages->avg();
$todayDow = (int) now()->format('w') + 1; // PHP 0=Sun → MySQL 1=Sun
$todayAvg = $dowAverages->get($todayDow, $weekAvg);
$cheapestDow = $dowAverages->keys()->sortBy(fn ($k) => $dowAverages[$k])->first();
$dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
$todayName = $dayNames[($todayDow - 1) % 7] ?? 'Today';
$tomorrowName = $dayNames[$todayDow % 7] ?? 'Tomorrow';
$todayDeltaPence = round(($todayAvg - $weekAvg) / 100, 1);
$tomorrowDeltaPence = round(($dowAverages->get(($todayDow % 7) + 1, $weekAvg) - $todayAvg) / 100, 1);
$direction = match (true) {
($todayAvg - $weekAvg) / 100 >= 1.5 => 'up',
($weekAvg - $todayAvg) / 100 >= 1.5 => 'down',
default => 'stable',
};
$score = $direction === 'stable' ? 0.0 : ($direction === 'up' ? 1.0 : -1.0);
$parts = [];
$parts[] = abs($todayDeltaPence) < 0.1
? "Today ({$todayName}) is typically in line with the weekly average."
: sprintf(
'Today (%s) is typically %sp %s the weekly average.',
$todayName,
number_format(abs($todayDeltaPence), 1),
$todayDeltaPence > 0 ? 'above' : 'below',
);
$parts[] = abs($tomorrowDeltaPence) < 0.1
? "Tomorrow ({$tomorrowName}) is typically the same."
: sprintf(
'Tomorrow (%s) is typically %sp %s.',
$tomorrowName,
number_format(abs($tomorrowDeltaPence), 1),
$tomorrowDeltaPence < 0 ? 'cheaper' : 'pricier',
);
if ($cheapestDow === $todayDow) {
$parts[] = 'Today is historically the cheapest day of the week.';
}
return [
'score' => $score,
'confidence' => min(1.0, $uniqueDays / 90),
'direction' => $direction,
'detail' => implode(' ', $parts),
'data_points' => $uniqueDays,
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
/**
* SQL dialect helpers for the small set of MySQL/SQLite differences the
* signal classes care about. Centralises the isSqlite ternaries that were
* duplicated across DayOfWeekSignal and StickinessSignal.
*/
final class DbDialect
{
private static function isSqlite(): bool
{
return DB::connection()->getDriverName() === 'sqlite';
}
/**
* Day-of-week expression returning 1=Sun..7=Sat (MySQL DAYOFWEEK convention).
* Targets a column on the queried table.
*/
public static function dayOfWeekExpr(string $column): string
{
return self::isSqlite()
? "(CAST(strftime('%w', {$column}) AS INTEGER) + 1)"
: "DAYOFWEEK({$column})";
}
/**
* Whole-day difference between MAX and MIN of a datetime column, suitable
* for use in an aggregate selectRaw.
*/
public static function maxMinDayDiffExpr(string $column): string
{
return self::isSqlite()
? "CAST((julianday(MAX({$column})) - julianday(MIN({$column}))) AS INTEGER)"
: "DATEDIFF(MAX({$column}), MIN({$column}))";
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class OilSignal extends AbstractSignal
{
/**
* Reads the most recent Brent crude prediction (LLM preferred, EWMA
* fallback) covering today or later. Sourced from price_predictions,
* which OilPriceService populates daily.
*/
public function compute(SignalContext $context): array
{
$prediction = null;
foreach (['llm_with_context', 'llm', 'ewma'] as $source) {
$prediction = DB::table('price_predictions')
->where('source', $source)
->where('predicted_for', '>=', now()->toDateString())
->orderByDesc('predicted_for')
->orderByDesc('generated_at')
->first();
if ($prediction !== null) {
break;
}
}
if ($prediction === null) {
return $this->disabledSignal('No oil price prediction available');
}
$direction = match ($prediction->direction) {
'rising' => 'up',
'falling' => 'down',
default => 'stable',
};
$score = match ($direction) {
'up' => 1.0,
'down' => -1.0,
default => 0.0,
};
$confidence = round(((float) $prediction->confidence) / 100, 2);
return [
'score' => $score,
'confidence' => $confidence,
'direction' => $direction,
'detail' => sprintf(
'Brent crude %s (%s, %d%% confidence)',
$prediction->direction,
$prediction->source,
(int) $prediction->confidence,
),
'data_points' => 1,
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Services\HaversineQuery;
use Illuminate\Support\Facades\DB;
final class RegionalMomentumSignal extends AbstractSignal
{
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float REGIONAL_RADIUS_KM = 50.0;
public function compute(SignalContext $context): array
{
if (! $context->hasCoordinates()) {
return $this->disabledSignal('No coordinates provided for regional momentum analysis');
}
[$radiusSql, $radiusBindings] = HaversineQuery::withinKm($context->lat, $context->lng, self::REGIONAL_RADIUS_KM);
$rows = DB::table('station_prices')
->join('stations', 'station_prices.station_id', '=', 'stations.node_id')
->where('station_prices.fuel_type', $context->fuelType->value)
->where('station_prices.price_effective_at', '>=', now()->subDays(14))
->whereRaw($radiusSql, $radiusBindings)
->selectRaw('DATE(station_prices.price_effective_at) as day, AVG(station_prices.price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 3) {
return $this->disabledSignal('Insufficient regional data');
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
$direction = match (true) {
$regression['slope'] >= self::SLOPE_THRESHOLD_PENCE => 'up',
$regression['slope'] <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
return [
'score' => $direction === 'stable' ? 0.0 : ($direction === 'up' ? 0.7 : -0.7),
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => 'Regional trend: '.round($regression['slope'], 2).'p/day (R²='.round($regression['r_squared'], 2).')',
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Prediction\Signals;
interface Signal
{
/**
* Evaluate the signal against the given context.
*
* Returns the canonical signal payload. Implementations may add extra
* keys beyond the base shape (e.g. trend adds slope + r_squared).
*
* @return array{
* score: float,
* confidence: float,
* direction: string,
* detail: string,
* data_points: int,
* enabled: bool,
* ...
* }
*/
public function compute(SignalContext $context): array;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Services\Prediction\Signals;
use App\Enums\FuelType;
/**
* Inputs required to evaluate a prediction signal. Individual signals may
* ignore fields they don't need — for example OilSignal doesn't use fuelType,
* RegionalMomentumSignal requires lat/lng to be non-null.
*/
final readonly class SignalContext
{
public function __construct(
public FuelType $fuelType,
public ?float $lat = null,
public ?float $lng = null,
) {}
public function hasCoordinates(): bool
{
return $this->lat !== null && $this->lng !== null;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class StickinessSignal extends AbstractSignal
{
public function compute(SignalContext $context): array
{
$diffExpr = DbDialect::maxMinDayDiffExpr('price_effective_at');
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays(30))
->selectRaw("station_id, COUNT(*) as changes, {$diffExpr} as span_days")
->groupBy('station_id')
->having('changes', '>', 1)
->having('span_days', '>', 0)
->get();
if ($rows->count() < 10) {
return $this->disabledSignal('Insufficient stickiness data (need 10+ stations with price history)');
}
$avgHoldDays = $rows->avg(fn ($r) => $r->span_days / ($r->changes - 1));
$avgHoldDays = round((float) $avgHoldDays, 1);
$score = match (true) {
$avgHoldDays < 2 => -0.1,
$avgHoldDays > 5 => 0.1,
default => 0.0,
};
$detail = match (true) {
$avgHoldDays < 2 => "Volatile prices (avg hold: {$avgHoldDays} days) — harder to predict.",
$avgHoldDays > 5 => "Sticky prices (avg hold: {$avgHoldDays} days) — more predictable.",
default => "Normal hold period (avg: {$avgHoldDays} days).",
};
return [
'score' => $score,
'confidence' => min(1.0, $rows->count() / 200),
'direction' => 'stable',
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Services\Prediction\Signals;
use Illuminate\Support\Facades\DB;
final class TrendSignal extends AbstractSignal
{
private const float R_SQUARED_THRESHOLD = 0.5;
private const float SLOPE_THRESHOLD_PENCE = 0.3;
private const float SLOPE_SATURATION_PENCE = 0.5;
private const int PREDICTION_HORIZON_DAYS = 7;
/** @return array{score: float, confidence: float, direction: string, detail: string, data_points: int, enabled: bool, slope: float, r_squared: float} */
public function compute(SignalContext $context): array
{
foreach ([5, 14] as $lookbackDays) {
$rows = DB::table('station_prices')
->where('fuel_type', $context->fuelType->value)
->where('price_effective_at', '>=', now()->subDays($lookbackDays))
->selectRaw('DATE(price_effective_at) as day, AVG(price_pence) as avg_price')
->groupBy('day')
->orderBy('day')
->get();
if ($rows->count() < 2) {
continue;
}
$regression = $this->linearRegression($rows->pluck('avg_price')->map(fn ($v) => (float) $v / 100)->values()->all());
if ($regression['r_squared'] >= self::R_SQUARED_THRESHOLD) {
$slope = $regression['slope'];
$direction = match (true) {
$slope >= self::SLOPE_THRESHOLD_PENCE => 'up',
$slope <= -self::SLOPE_THRESHOLD_PENCE => 'down',
default => 'stable',
};
$absSlope = abs($slope);
$score = $direction === 'stable' ? 0.0 : min(1.0, $absSlope / self::SLOPE_SATURATION_PENCE) * ($slope > 0 ? 1 : -1);
$projected = round($slope * $lookbackDays, 1);
$detail = $direction === 'stable'
? "Prices flat over {$lookbackDays} days (slope: {$slope}p/day, R²={$regression['r_squared']})"
: sprintf(
'%s at %sp/day over %d days (R²=%s, ~%s%sp in %dd)',
$slope > 0 ? 'Rising' : 'Falling',
abs(round($slope, 2)),
$lookbackDays,
round($regression['r_squared'], 2),
$projected > 0 ? '+' : '',
$projected,
self::PREDICTION_HORIZON_DAYS,
);
if ($lookbackDays === 5) {
$detail .= ' [Adaptive lookback active]';
}
return [
'score' => $score,
'confidence' => min(1.0, $regression['r_squared']),
'direction' => $direction,
'detail' => $detail,
'data_points' => $rows->count(),
'enabled' => true,
'slope' => round($slope, 3),
'r_squared' => round($regression['r_squared'], 3),
];
}
}
return [
'score' => 0.0,
'confidence' => 0.0,
'direction' => 'stable',
'detail' => 'Insufficient price history or noisy data (R² below threshold)',
'data_points' => 0,
'enabled' => false,
'slope' => 0.0,
'r_squared' => 0.0,
];
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Services\StationSearch;
use App\Enums\FuelType;
final readonly class SearchCriteria
{
public function __construct(
public float $lat,
public float $lng,
public FuelType $fuelType,
public float $radiusKm,
public string $sort,
) {}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Services\StationSearch;
use Illuminate\Support\Collection;
final readonly class SearchResult
{
/**
* @param Collection<int, mixed> $stations Sorted station rows with _updated_at/_reliability/_classification cached
* @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary
* @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts
* @param array<string, mixed> $prediction
*/
public function __construct(
public Collection $stations,
public array $pricesSummary,
public array $reliabilityCounts,
public array $prediction,
) {}
}

View File

@@ -0,0 +1,151 @@
<?php
namespace App\Services\StationSearch;
use App\Enums\PriceClassification;
use App\Enums\PriceReliability;
use App\Models\Search;
use App\Models\Station;
use App\Models\User;
use App\Services\HaversineQuery;
use App\Services\NationalFuelPredictionService;
use App\Services\PlanFeatures;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
final class StationSearchService
{
public function __construct(
private readonly NationalFuelPredictionService $predictionService,
) {}
public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult
{
$stations = $this->fetchAndSortStations($criteria);
$prices = $stations->pluck('price_pence');
$this->logSearch($criteria, $stations->count(), $prices, $ipHash);
return new SearchResult(
stations: $stations,
pricesSummary: [
'lowest' => $prices->min(),
'highest' => $prices->max(),
'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
],
reliabilityCounts: $this->countReliability($stations),
prediction: $this->buildPrediction($user, $criteria),
);
}
/** @return Collection<int, mixed> */
private function fetchAndSortStations(SearchCriteria $criteria): Collection
{
[$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng);
$all = Station::query()
->selectRaw(
"stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km",
$distanceBindings,
)
->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void {
$join->on('stations.node_id', '=', 'spc.station_id')
->where('spc.fuel_type', '=', $criteria->fuelType->value);
})
->where('stations.temporary_closure', false)
->where('stations.permanent_closure', false)
->get();
// Compute reliability + classification once per row so the sort, the
// count groupBy, and the StationResource render all read cached
// values instead of re-invoking PriceReliability::fromUpdatedAt.
$all->each(function ($s): void {
$updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null;
$s->_updated_at = $updatedAt;
$s->_reliability = PriceReliability::fromUpdatedAt($updatedAt);
$s->_classification = PriceClassification::fromUpdatedAt($updatedAt);
});
$filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm);
return $this->applySort($filtered, $criteria->sort);
}
/**
* @param Collection<int, mixed> $filtered
* @return Collection<int, mixed>
*/
private function applySort(Collection $filtered, string $sort): Collection
{
if ($sort === 'reliable') {
return $filtered
->sort(function ($a, $b) {
return $a->_reliability->weight() <=> $b->_reliability->weight()
?: ((int) $a->price_pence <=> (int) $b->price_pence)
?: ((float) $a->distance_km <=> (float) $b->distance_km);
})
->values();
}
return $filtered->sortBy(match ($sort) {
'price' => fn ($s) => (int) $s->price_pence,
'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX,
default => fn ($s) => (float) $s->distance_km,
})->values();
}
/**
* @param Collection<int, mixed> $stations
* @return array{reliable: int, stale: int, outdated: int}
*/
private function countReliability(Collection $stations): array
{
$counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count();
return [
'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0),
'stale' => (int) $counts->get(PriceReliability::Stale->value, 0),
'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0),
];
}
/** @param Collection<int, mixed> $prices */
private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void
{
Search::create([
'lat_bucket' => round($criteria->lat, 2),
'lng_bucket' => round($criteria->lng, 2),
'fuel_type' => $criteria->fuelType->value,
'results_count' => $resultsCount,
'lowest_pence' => $prices->min(),
'highest_pence' => $prices->max(),
'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null,
'searched_at' => now(),
'ip_hash' => $ipHash ?? hash('sha256', ''),
]);
}
/**
* Free/guest users get a stripped teaser; users with the ai_predictions
* feature get the full multi-signal payload.
*
* @return array<string, mixed>
*/
private function buildPrediction(?User $user, SearchCriteria $criteria): array
{
$result = $this->predictionService->predict($criteria->lat, $criteria->lng);
$canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions');
if (! $canSeeFull) {
return [
'fuel_type' => $result['fuel_type'],
'predicted_direction' => $result['predicted_direction'],
'tier_locked' => true,
];
}
return $result;
}
}

View File

@@ -1,5 +1,6 @@
<?php <?php
use App\Http\Middleware\RequiresFeature;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware; use Illuminate\Foundation\Configuration\Middleware;
@@ -14,6 +15,9 @@ return Application::configure(basePath: dirname(__DIR__))
) )
->withMiddleware(function (Middleware $middleware): void { ->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi(); $middleware->statefulApi();
$middleware->alias([
'feature' => RequiresFeature::class,
]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*')); $exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*'));

View File

@@ -8,6 +8,7 @@
"require": { "require": {
"php": "^8.4", "php": "^8.4",
"filament/filament": "^5.0", "filament/filament": "^5.0",
"laravel/cashier": "^16.5",
"laravel/fortify": "^1.34", "laravel/fortify": "^1.34",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/sanctum": "^4.0", "laravel/sanctum": "^4.0",

328
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "789a2e6b542a1e2f263dc8e9c973423b", "content-hash": "9035b4713dec553cc69f487efa60cade",
"packages": [ "packages": [
{ {
"name": "bacon/bacon-qr-code", "name": "bacon/bacon-qr-code",
@@ -2124,6 +2124,95 @@
}, },
"time": "2026-03-29T12:05:03+00:00" "time": "2026-03-29T12:05:03+00:00"
}, },
{
"name": "laravel/cashier",
"version": "v16.5.1",
"source": {
"type": "git",
"url": "https://github.com/laravel/cashier-stripe.git",
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/cashier-stripe/zipball/49a581bccb5e56a45e1c8ee94587ce3420203a7a",
"reference": "49a581bccb5e56a45e1c8ee94587ce3420203a7a",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^10.0|^11.0|^12.0|^13.0",
"illuminate/contracts": "^10.0|^11.0|^12.0|^13.0",
"illuminate/database": "^10.0|^11.0|^12.0|^13.0",
"illuminate/http": "^10.0|^11.0|^12.0|^13.0",
"illuminate/log": "^10.0|^11.0|^12.0|^13.0",
"illuminate/notifications": "^10.0|^11.0|^12.0|^13.0",
"illuminate/pagination": "^10.0|^11.0|^12.0|^13.0",
"illuminate/routing": "^10.0|^11.0|^12.0|^13.0",
"illuminate/support": "^10.0|^11.0|^12.0|^13.0",
"illuminate/view": "^10.0|^11.0|^12.0|^13.0",
"moneyphp/money": "^4.0",
"nesbot/carbon": "^2.0|^3.0",
"php": "^8.1",
"stripe/stripe-php": "^17.3.0",
"symfony/console": "^6.0|^7.0|^8.0",
"symfony/http-kernel": "^6.0|^7.0|^8.0",
"symfony/polyfill-intl-icu": "^1.22.1",
"symfony/polyfill-php84": "^1.32"
},
"require-dev": {
"dompdf/dompdf": "^2.0|^3.0",
"orchestra/testbench": "^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10",
"spatie/laravel-ray": "^1.40"
},
"suggest": {
"dompdf/dompdf": "Required when generating and downloading invoice PDF's using Dompdf (^2.0|^3.0).",
"ext-intl": "Allows for more locales besides the default \"en\" when formatting money values.",
"spatie/laravel-pdf": "Required when generating and downloading invoice PDF's using Cashier's LaravelPdfInvoiceRenderer."
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Cashier\\CashierServiceProvider"
]
},
"branch-alias": {
"dev-master": "16.x-dev"
}
},
"autoload": {
"psr-4": {
"Laravel\\Cashier\\": "src/",
"Laravel\\Cashier\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Dries Vints",
"email": "dries@laravel.com"
}
],
"description": "Laravel Cashier provides an expressive, fluent interface to Stripe's subscription billing services.",
"keywords": [
"billing",
"laravel",
"stripe"
],
"support": {
"issues": "https://github.com/laravel/cashier/issues",
"source": "https://github.com/laravel/cashier"
},
"time": "2026-04-01T15:57:36+00:00"
},
{ {
"name": "laravel/fortify", "name": "laravel/fortify",
"version": "v1.36.2", "version": "v1.36.2",
@@ -3537,6 +3626,96 @@
], ],
"time": "2026-04-02T20:48:35+00:00" "time": "2026-04-02T20:48:35+00:00"
}, },
{
"name": "moneyphp/money",
"version": "v4.8.0",
"source": {
"type": "git",
"url": "https://github.com/moneyphp/money.git",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/moneyphp/money/zipball/b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"reference": "b358727ea5a5cd2d7475e59c31dfc352440ae7ec",
"shasum": ""
},
"require": {
"ext-bcmath": "*",
"ext-filter": "*",
"ext-json": "*",
"php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cache/taggable-cache": "^1.1.0",
"doctrine/coding-standard": "^12.0",
"doctrine/instantiator": "^1.5.0 || ^2.0",
"ext-gmp": "*",
"ext-intl": "*",
"florianv/exchanger": "^2.8.1",
"florianv/swap": "^4.3.0",
"moneyphp/crypto-currencies": "^1.1.0",
"moneyphp/iso-currencies": "^3.4",
"php-http/message": "^1.16.0",
"php-http/mock-client": "^1.6.0",
"phpbench/phpbench": "^1.2.5",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.1.9",
"phpstan/phpstan-phpunit": "^2.0",
"phpunit/phpunit": "^10.5.9",
"psr/cache": "^1.0.1 || ^2.0 || ^3.0",
"ticketswap/phpstan-error-formatter": "^1.1"
},
"suggest": {
"ext-gmp": "Calculate without integer limits",
"ext-intl": "Format Money objects with intl",
"florianv/exchanger": "Exchange rates library for PHP",
"florianv/swap": "Exchange rates library for PHP",
"psr/cache-implementation": "Used for Currency caching"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Money\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mathias Verraes",
"email": "mathias@verraes.net",
"homepage": "http://verraes.net"
},
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
},
{
"name": "Frederik Bosch",
"email": "f.bosch@genkgo.nl"
}
],
"description": "PHP implementation of Fowler's Money pattern",
"homepage": "http://moneyphp.org",
"keywords": [
"Value Object",
"money",
"vo"
],
"support": {
"issues": "https://github.com/moneyphp/money/issues",
"source": "https://github.com/moneyphp/money/tree/v4.8.0"
},
"time": "2025-10-23T07:55:09+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.10.0", "version": "3.10.0",
@@ -5508,6 +5687,65 @@
], ],
"time": "2026-02-01T09:30:04+00:00" "time": "2026-02-01T09:30:04+00:00"
}, },
{
"name": "stripe/stripe-php",
"version": "v17.6.0",
"source": {
"type": "git",
"url": "https://github.com/stripe/stripe-php.git",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/stripe/stripe-php/zipball/a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"reference": "a6219df5df1324a0d3f1da25fb5e4b8a3307ea16",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"php": ">=5.6.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "3.72.0",
"phpstan/phpstan": "^1.2",
"phpunit/phpunit": "^5.7 || ^9.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.0-dev"
}
},
"autoload": {
"psr-4": {
"Stripe\\": "lib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Stripe and contributors",
"homepage": "https://github.com/stripe/stripe-php/contributors"
}
],
"description": "Stripe PHP Library",
"homepage": "https://stripe.com/",
"keywords": [
"api",
"payment processing",
"stripe"
],
"support": {
"issues": "https://github.com/stripe/stripe-php/issues",
"source": "https://github.com/stripe/stripe-php/tree/v17.6.0"
},
"time": "2025-08-27T19:32:42+00:00"
},
{ {
"name": "symfony/clock", "name": "symfony/clock",
"version": "v8.0.8", "version": "v8.0.8",
@@ -6708,6 +6946,94 @@
], ],
"time": "2025-06-27T09:58:17+00:00" "time": "2025-06-27T09:58:17+00:00"
}, },
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.35.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-icu.git",
"reference": "3510b63d07376b04e57e27e82607d468bb134f78"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78",
"reference": "3510b63d07376b04e57e27e82607d468bb134f78",
"shasum": ""
},
"require": {
"php": ">=7.2"
},
"suggest": {
"ext-intl": "For best performance and support of other locales than \"en\""
},
"type": "library",
"extra": {
"thanks": {
"url": "https://github.com/symfony/polyfill",
"name": "symfony/polyfill"
}
},
"autoload": {
"files": [
"bootstrap.php"
],
"psr-4": {
"Symfony\\Polyfill\\Intl\\Icu\\": ""
},
"classmap": [
"Resources/stubs"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's ICU-related data and classes",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"icu",
"intl",
"polyfill",
"portable",
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.35.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-04-10T16:50:15+00:00"
},
{ {
"name": "symfony/polyfill-intl-idn", "name": "symfony/polyfill-intl-idn",
"version": "v1.33.0", "version": "v1.33.0",

View File

@@ -45,6 +45,10 @@ return [
'api_key' => env('FRED_API_KEY'), 'api_key' => env('FRED_API_KEY'),
], ],
'eia' => [
'api_key' => env('EIA_API_KEY'),
],
'anthropic' => [ 'anthropic' => [
'api_key' => env('ANTHROPIC_API_KEY'), 'api_key' => env('ANTHROPIC_API_KEY'),
'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'), 'model' => env('ANTHROPIC_MODEL', 'claude-sonnet-4-6'),
@@ -68,4 +72,33 @@ return [
'api_key' => env('FUELALERT_API_KEY'), 'api_key' => env('FUELALERT_API_KEY'),
], ],
'onesignal' => [
'app_id' => env('ONESIGNAL_APP_ID'),
'api_key' => env('ONESIGNAL_API_KEY'),
],
'vonage' => [
'key' => env('VONAGE_KEY'),
'secret' => env('VONAGE_SECRET'),
'whatsapp_from' => env('VONAGE_WHATSAPP_FROM'),
'sms_from' => env('VONAGE_SMS_FROM', 'FuelAlert'),
],
'stripe' => [
'prices' => [
'basic' => [
'monthly' => env('STRIPE_PRICE_BASIC_MONTHLY'),
'annual' => env('STRIPE_PRICE_BASIC_ANNUAL'),
],
'plus' => [
'monthly' => env('STRIPE_PRICE_PLUS_MONTHLY'),
'annual' => env('STRIPE_PRICE_PLUS_ANNUAL'),
],
'pro' => [
'monthly' => env('STRIPE_PRICE_PRO_MONTHLY'),
'annual' => env('STRIPE_PRICE_PRO_ANNUAL'),
],
],
],
]; ];

View File

@@ -0,0 +1,36 @@
<?php
namespace Database\Factories;
use App\Enums\FuelType;
use App\Models\NotificationLog;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<NotificationLog>
*/
class NotificationLogFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
'trigger_type' => fake()->randomElement(['price_threshold', 'score_change', 'scheduled_morning', 'scheduled_evening']),
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
'price' => fake()->optional()->randomFloat(3, 100, 180),
'sent' => true,
'missed_reason' => null,
'created_at' => now(),
];
}
public function missed(string $reason = 'daily_limit'): static
{
return $this->state(fn () => [
'sent' => false,
'missed_reason' => $reason,
]);
}
}

View File

@@ -0,0 +1,111 @@
<?php
namespace Database\Factories;
use App\Enums\PlanTier;
use App\Models\Plan;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<Plan>
*/
class PlanFactory extends Factory
{
public function definition(): array
{
return [
'name' => PlanTier::Free->value,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'weekly_digest',
'push_enabled' => false,
'push_frequency' => 'none',
'whatsapp_enabled' => false,
'whatsapp_daily_limit' => 0,
'whatsapp_scheduled_updates' => 0,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => false,
'score_alerts' => false,
'active' => true,
];
}
public function free(): static
{
return $this->state(fn () => [
'name' => PlanTier::Free->value,
'stripe_price_id_monthly' => null,
'stripe_price_id_annual' => null,
]);
}
public function basic(): static
{
return $this->state(fn () => [
'name' => PlanTier::Basic->value,
'stripe_price_id_monthly' => 'price_basic_monthly_test',
'stripe_price_id_annual' => 'price_basic_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'daily',
'push_enabled' => true,
'push_frequency' => 'daily',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => false,
'sms_daily_limit' => 0,
'ai_predictions' => false,
'price_threshold' => true,
'score_alerts' => true,
]);
}
public function plus(): static
{
return $this->state(fn () => [
'name' => PlanTier::Plus->value,
'stripe_price_id_monthly' => 'price_plus_monthly_test',
'stripe_price_id_annual' => 'price_plus_annual_test',
'max_fuel_types' => 1,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 1,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
]);
}
public function pro(): static
{
return $this->state(fn () => [
'name' => PlanTier::Pro->value,
'stripe_price_id_monthly' => 'price_pro_monthly_test',
'stripe_price_id_annual' => 'price_pro_annual_test',
'max_fuel_types' => null,
'email_enabled' => true,
'email_frequency' => 'triggered',
'push_enabled' => true,
'push_frequency' => 'triggered',
'whatsapp_enabled' => true,
'whatsapp_daily_limit' => 5,
'whatsapp_scheduled_updates' => 2,
'sms_enabled' => true,
'sms_daily_limit' => 3,
'ai_predictions' => true,
'price_threshold' => true,
'score_alerts' => true,
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Database\Factories;
use App\Enums\FuelType;
use App\Models\User;
use App\Models\UserNotificationPreference;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<UserNotificationPreference>
*/
class UserNotificationPreferenceFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'channel' => fake()->randomElement(['email', 'push', 'whatsapp', 'sms']),
'fuel_type' => fake()->randomElement(array_column(FuelType::cases(), 'value')),
'enabled' => true,
];
}
}

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